fix : 모델, BOM 구성 수정

- 설계용 모델, BOM 기능 추가
This commit is contained in:
2025-09-05 17:59:34 +09:00
parent 41d0afa245
commit d9563c96cb
19 changed files with 1972 additions and 290 deletions

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Design\BomTemplateService;
use Illuminate\Http\Request;
class BomTemplateController extends Controller
{
public function __construct(
protected BomTemplateService $service
) {}
public function listByVersion(int $versionId)
{
return ApiResponse::handle(function () use ($versionId) {
// 내부에서 tenant 스코프 처리
return $this->service->paginate($versionId, page: 1, size: PHP_INT_MAX)->items();
}, __('message.fetched'));
}
public function upsertTemplate(Request $request, int $versionId)
{
return ApiResponse::handle(function () use ($request, $versionId) {
$payload = $request->validate([
'name' => 'nullable|string|max:100',
'is_primary' => 'boolean',
'notes' => 'nullable|string',
]);
return $this->service->upsertTemplate(
modelVersionId: $versionId,
name: $payload['name'] ?? 'Main',
isPrimary: (bool)($payload['is_primary'] ?? true),
notes: $payload['notes'] ?? null
);
}, __('message.created'));
}
public function show(int $templateId)
{
return ApiResponse::handle(fn() => $this->service->show($templateId, true), __('message.fetched'));
}
public function replaceItems(Request $request, int $templateId)
{
return ApiResponse::handle(function () use ($request, $templateId) {
$payload = $request->validate([
'items' => 'required|array|min:1',
'items.*.ref_type' => 'required|string|in:MATERIAL,PRODUCT',
'items.*.ref_id' => 'required|integer|min:1',
'items.*.qty' => 'required|numeric|gt:0',
'items.*.waste_rate' => 'nullable|numeric|min:0',
'items.*.uom_id' => 'nullable|integer|min:1',
'items.*.notes' => 'nullable|string|max:255',
'items.*.sort_order' => 'nullable|integer',
]);
$this->service->replaceItems($templateId, $payload['items']);
return null;
}, __('message.bom.bulk_upsert'));
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Design\ModelService;
use Illuminate\Http\Request;
class DesignModelController extends Controller
{
public function __construct(
protected ModelService $service
) {}
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$q = $request->query('q', '');
$page = (int) $request->query('page', 1);
$size = (int) $request->query('size', 20);
return $this->service->list($q, $page, $size);
}, __('message.fetched'));
}
public function store(Request $request)
{
return ApiResponse::handle(function () use ($request) {
$payload = $request->validate([
'code' => 'required|string|max:100',
'name' => 'required|string|max:200',
'category_id' => 'nullable|integer',
'lifecycle' => 'nullable|string|max:30',
'description' => 'nullable|string',
'is_active' => 'boolean',
]);
return $this->service->create($payload);
}, __('message.created'));
}
public function show(int $id)
{
return ApiResponse::handle(fn() => $this->service->find($id), __('message.fetched'));
}
public function update(Request $request, int $id)
{
return ApiResponse::handle(function () use ($request, $id) {
$payload = $request->validate([
'code' => 'sometimes|string|max:100',
'name' => 'sometimes|string|max:200',
'category_id' => 'nullable|integer',
'lifecycle' => 'nullable|string|max:30',
'description' => 'nullable|string',
'is_active' => 'boolean',
]);
return $this->service->update($id, $payload);
}, __('message.updated'));
}
public function destroy(int $id)
{
return ApiResponse::handle(function () use ($id) {
$this->service->delete($id);
return null;
}, __('message.deleted'));
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api\V1\Design;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Services\Design\ModelVersionService;
use Illuminate\Http\Request;
class ModelVersionController extends Controller
{
public function __construct(
protected ModelVersionService $service
) {}
public function index(int $modelId)
{
return ApiResponse::handle(fn() => $this->service->listByModel($modelId), __('message.fetched'));
}
public function createDraft(Request $request, int $modelId)
{
return ApiResponse::handle(function () use ($request, $modelId) {
$payload = $request->validate([
'version_no' => 'nullable|integer|min:1',
'notes' => 'nullable|string',
'effective_from' => 'nullable|date',
'effective_to' => 'nullable|date|after:effective_from',
]);
return $this->service->createDraft($modelId, $payload);
}, __('message.created'));
}
public function release(int $versionId)
{
return ApiResponse::handle(fn() => $this->service->release($versionId), __('message.updated'));
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models\Design;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class BomTemplate extends Model
{
use SoftDeletes;
protected $table = 'bom_templates';
protected $fillable = [
'tenant_id','model_version_id','name','is_primary','notes',
];
protected $casts = [
'is_primary' => 'boolean',
];
public function modelVersion() {
return $this->belongsTo(ModelVersion::class, 'model_version_id');
}
public function items() {
return $this->hasMany(BomTemplateItem::class, 'bom_template_id');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\Design;
use Illuminate\Database\Eloquent\Model;
class BomTemplateItem extends Model
{
protected $table = 'bom_template_items';
protected $fillable = [
'tenant_id','bom_template_id','ref_type','ref_id','qty','waste_rate','uom_id','notes','sort_order',
];
protected $casts = [
'qty' => 'decimal:6',
'waste_rate' => 'decimal:6',
];
public function template() {
return $this->belongsTo(BomTemplate::class, 'bom_template_id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models\Design;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class DesignModel extends Model
{
use SoftDeletes;
protected $table = 'models';
protected $fillable = [
'tenant_id','code','name','category_id','lifecycle','description','is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
// 관계: 모델은 여러 버전을 가짐
public function versions() {
return $this->hasMany(ModelVersion::class, 'model_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models\Design;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ModelVersion extends Model
{
use SoftDeletes;
protected $table = 'model_versions';
protected $fillable = [
'tenant_id','model_id','version_no','status','effective_from','effective_to','notes','is_active',
];
protected $casts = [
'is_active' => 'boolean',
'effective_from' => 'datetime',
'effective_to' => 'datetime',
];
public function model() {
return $this->belongsTo(DesignModel::class, 'model_id');
}
public function bomTemplates() {
return $this->hasMany(BomTemplate::class, 'model_version_id');
}
public function scopeReleased($q) {
return $q->where('status', 'RELEASED');
}
}

View File

@@ -0,0 +1,221 @@
<?php
namespace App\Services\Design;
use App\Models\Design\BomTemplate;
use App\Models\Design\BomTemplateItem;
use App\Models\Design\ModelVersion;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BomTemplateService extends Service
{
/** 페이징 목록(옵션: 모델버전 필터) */
public function paginate(?int $modelVersionId, int $page = 1, int $size = 20): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$q = BomTemplate::query()->where('tenant_id', $tenantId);
if ($modelVersionId) {
$q->where('model_version_id', $modelVersionId);
}
return $q->orderByDesc('is_primary')
->orderBy('name')
->paginate($size, ['*'], 'page', $page);
}
/** 템플릿 upsert(name 기준) */
public function upsertTemplate(int $modelVersionId, string $name = 'Main', bool $isPrimary = true, ?string $notes = null): BomTemplate
{
$tenantId = $this->tenantId();
$mv = ModelVersion::query()
->where('tenant_id', $tenantId)
->find($modelVersionId);
if (!$mv) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $mv, $name, $isPrimary, $notes) {
$tpl = BomTemplate::query()
->where('model_version_id', $mv->id)
->where('name', $name)
->first();
if (!$tpl) {
$tpl = BomTemplate::create([
'tenant_id' => $tenantId,
'model_version_id' => $mv->id,
'name' => $name,
'is_primary' => $isPrimary,
'notes' => $notes,
]);
} else {
$tpl->fill(['is_primary' => $isPrimary, 'notes' => $notes])->save();
}
if ($isPrimary) {
BomTemplate::query()
->where('model_version_id', $mv->id)
->where('id', '<>', $tpl->id)
->update(['is_primary' => false]);
}
return $tpl;
});
}
/** 템플릿 메타 수정 */
public function updateTemplate(int $templateId, array $data): BomTemplate
{
$tenantId = $this->tenantId();
$tpl = BomTemplate::query()
->where('tenant_id', $tenantId)
->find($templateId);
if (!$tpl) {
throw new NotFoundHttpException(__('error.not_found'));
}
$name = $data['name'] ?? $tpl->name;
if ($name !== $tpl->name) {
$dup = BomTemplate::query()
->where('model_version_id', $tpl->model_version_id)
->where('name', $name)
->where('id', '<>', $tpl->id)
->exists();
if ($dup) {
throw ValidationException::withMessages(['name' => __('error.duplicate')]);
}
}
$tpl->fill($data)->save();
if (array_key_exists('is_primary', $data) && $data['is_primary']) {
// 다른 템플릿 대표 해제
BomTemplate::query()
->where('model_version_id', $tpl->model_version_id)
->where('id', '<>', $tpl->id)
->update(['is_primary' => false]);
}
return $tpl;
}
/** 템플릿 삭제(soft) */
public function deleteTemplate(int $templateId): void
{
$tenantId = $this->tenantId();
$tpl = BomTemplate::query()
->where('tenant_id', $tenantId)
->find($templateId);
if (!$tpl) {
throw new NotFoundHttpException(__('error.not_found'));
}
$tpl->delete();
}
/** 템플릿 상세 (옵션: 항목 포함) */
public function show(int $templateId, bool $withItems = false): BomTemplate
{
$tenantId = $this->tenantId();
$q = BomTemplate::query()->where('tenant_id', $tenantId);
if ($withItems) {
$q->with(['items' => fn($w) => $w->orderBy('sort_order')]);
}
$tpl = $q->find($templateId);
if (!$tpl) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $tpl;
}
/** 항목 일괄 치환 */
public function replaceItems(int $templateId, array $items): void
{
$tenantId = $this->tenantId();
$tpl = BomTemplate::query()
->where('tenant_id', $tenantId)
->find($templateId);
if (!$tpl) {
throw new NotFoundHttpException(__('error.not_found'));
}
// 1차 검증
foreach ($items as $i => $row) {
$refType = strtoupper((string) Arr::get($row, 'ref_type'));
$qty = (float) Arr::get($row, 'qty', 0);
if (!in_array($refType, ['MATERIAL','PRODUCT'], true)) {
throw ValidationException::withMessages(["items.$i.ref_type" => __('error.validation_failed')]);
}
if ($qty <= 0) {
throw ValidationException::withMessages(["items.$i.qty" => __('error.validation_failed')]);
}
}
DB::transaction(function () use ($tenantId, $tpl, $items) {
BomTemplateItem::query()
->where('tenant_id', $tenantId)
->where('bom_template_id', $tpl->id)
->delete();
$now = now();
$payloads = [];
foreach ($items as $row) {
$payloads[] = [
'tenant_id' => $tenantId,
'bom_template_id' => $tpl->id,
'ref_type' => strtoupper($row['ref_type']),
'ref_id' => (int) $row['ref_id'],
'qty' => (string) ($row['qty'] ?? 1),
'waste_rate' => (string) ($row['waste_rate'] ?? 0),
'uom_id' => $row['uom_id'] ?? null,
'notes' => $row['notes'] ?? null,
'sort_order' => (int) ($row['sort_order'] ?? 0),
'created_at' => $now,
'updated_at' => $now,
];
}
if (!empty($payloads)) {
BomTemplateItem::insert($payloads);
}
});
}
/** 항목 조회 */
public function listItems(int $templateId)
{
$tenantId = $this->tenantId();
$tpl = BomTemplate::query()
->where('tenant_id', $tenantId)
->find($templateId);
if (!$tpl) {
throw new NotFoundHttpException(__('error.not_found'));
}
return BomTemplateItem::query()
->where('tenant_id', $tenantId)
->where('bom_template_id', $tpl->id)
->orderBy('sort_order')
->get();
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Services\Design;
use App\Models\Design\DesignModel;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ModelService extends Service
{
/** 목록 */
public function list(string $q = '', int $page = 1, int $size = 20): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = DesignModel::query()->where('tenant_id', $tenantId);
if ($q !== '') {
$query->where(function ($w) use ($q) {
$w->where('code', 'like', "%{$q}%")
->orWhere('name', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
return $query->orderByDesc('id')->paginate($size, ['*'], 'page', $page);
}
/** 생성 */
public function create(array $data): DesignModel
{
$tenantId = $this->tenantId();
$exists = DesignModel::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($exists) {
throw ValidationException::withMessages(['code' => __('error.duplicate')]);
}
return DB::transaction(function () use ($tenantId, $data) {
$payload = array_merge($data, ['tenant_id' => $tenantId]);
return DesignModel::create($payload);
});
}
/** 단건 */
public function find(int $id): DesignModel
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->with(['versions' => fn($q) => $q->orderBy('version_no')])
->find($id);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $model;
}
/** 수정 */
public function update(int $id, array $data): DesignModel
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->find($id);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
if (isset($data['code']) && $data['code'] !== $model->code) {
$dup = DesignModel::query()
->where('tenant_id', $tenantId)
->where('code', $data['code'])
->exists();
if ($dup) {
throw ValidationException::withMessages(['code' => __('error.duplicate')]);
}
}
$model->fill($data);
$model->save();
return $model;
}
/** 삭제(soft) */
public function delete(int $id): void
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->find($id);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
$model->delete();
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Services\Design;
use App\Models\Design\DesignModel;
use App\Models\Design\ModelVersion;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ModelVersionService extends Service
{
/** 특정 모델의 버전 목록 */
public function listByModel(int $modelId)
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->find($modelId);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return ModelVersion::query()
->where('tenant_id', $tenantId)
->where('model_id', $modelId)
->orderBy('version_no')
->get();
}
/** DRAFT 생성 (version_no 자동/수동) */
public function createDraft(int $modelId, array $extra = []): ModelVersion
{
$tenantId = $this->tenantId();
$model = DesignModel::query()
->where('tenant_id', $tenantId)
->find($modelId);
if (!$model) {
throw new NotFoundHttpException(__('error.not_found'));
}
return DB::transaction(function () use ($tenantId, $model, $extra) {
$versionNo = $extra['version_no'] ?? null;
if ($versionNo === null) {
$max = ModelVersion::query()
->where('model_id', $model->id)
->max('version_no');
$versionNo = (int)($max ?? 0) + 1;
} else {
$exists = ModelVersion::query()
->where('model_id', $model->id)
->where('version_no', $versionNo)
->exists();
if ($exists) {
throw ValidationException::withMessages(['version_no' => __('error.duplicate')]);
}
}
return ModelVersion::create([
'tenant_id' => $tenantId,
'model_id' => $model->id,
'version_no' => $versionNo,
'status' => 'DRAFT',
'is_active' => true,
'notes' => $extra['notes'] ?? null,
'effective_from' => $extra['effective_from'] ?? null,
'effective_to' => $extra['effective_to'] ?? null,
]);
});
}
/** RELEASED 전환 */
public function release(int $versionId): ModelVersion
{
$tenantId = $this->tenantId();
$mv = ModelVersion::query()
->where('tenant_id', $tenantId)
->find($versionId);
if (!$mv) {
throw new NotFoundHttpException(__('error.not_found'));
}
if ($mv->status === 'RELEASED') {
return $mv; // 멱등
}
// TODO: 대표 템플릿 존재 등 사전 검증 훅 가능
$mv->status = 'RELEASED';
$mv->effective_from = $mv->effective_from ?? now();
$mv->save();
return $mv;
}
}

View File

@@ -0,0 +1,397 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Model", description="모델(설계 상위) 관리")
* @OA\Tag(name="ModelVersion", description="모델 버전(DRAFT/RELEASED) 관리")
* @OA\Tag(name="BomTemplate", description="모델버전 기준 BOM 템플릿 관리")
*/
/* =========================
* 공통 스키마 (설계 영역)
* ========================= */
/**
* @OA\Schema(
* schema="DesignModel",
* type="object",
* required={"id","tenant_id","code","name"},
* @OA\Property(property="id", type="integer", example=101),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="code", type="string", example="KSS01"),
* @OA\Property(property="name", type="string", example="KSS 표준 모델"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=12),
* @OA\Property(property="lifecycle", type="string", nullable=true, example="ACTIVE"),
* @OA\Property(property="description", type="string", nullable=true, example="롤러 구조 표준 설계"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-09-05 10:11:12"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-09-05 10:11:12"),
* @OA\Property(property="deleted_at", type="string", format="date-time", nullable=true, example=null)
* )
*
* @OA\Schema(
* schema="DesignModelPagination",
* type="object",
* description="라라벨 LengthAwarePaginator",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/DesignModel")),
* @OA\Property(property="first_page_url", type="string", example="/api/v1/design/models?page=1"),
* @OA\Property(property="from", type="integer", example=1),
* @OA\Property(property="last_page", type="integer", example=5),
* @OA\Property(property="last_page_url", type="string", example="/api/v1/design/models?page=5"),
* @OA\Property(property="links", type="array", @OA\Items(
* type="object",
* @OA\Property(property="url", type="string", nullable=true, example=null),
* @OA\Property(property="label", type="string", example="&laquo; Previous"),
* @OA\Property(property="active", type="boolean", example=false)
* )),
* @OA\Property(property="next_page_url", type="string", nullable=true, example="/api/v1/design/models?page=2"),
* @OA\Property(property="path", type="string", example="/api/v1/design/models"),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="prev_page_url", type="string", nullable=true, example=null),
* @OA\Property(property="to", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=92)
* )
*
* @OA\Schema(
* schema="ModelVersion",
* type="object",
* required={"id","tenant_id","model_id","version_no","status"},
* @OA\Property(property="id", type="integer", example=201),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="model_id", type="integer", example=101),
* @OA\Property(property="version_no", type="integer", example=1),
* @OA\Property(property="status", type="string", example="RELEASED"),
* @OA\Property(property="effective_from", type="string", format="date-time", nullable=true, example="2025-09-05 11:00:00"),
* @OA\Property(property="effective_to", type="string", format="date-time", nullable=true, example=null),
* @OA\Property(property="notes", type="string", nullable=true, example="초도 릴리즈"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time"),
* @OA\Property(property="deleted_at", type="string", format="date-time", nullable=true, example=null)
* )
*
* @OA\Schema(
* schema="BomTemplate",
* type="object",
* required={"id","tenant_id","model_version_id","name"},
* @OA\Property(property="id", type="integer", example=301),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="model_version_id", type="integer", example=201),
* @OA\Property(property="name", type="string", example="Main"),
* @OA\Property(property="is_primary", type="boolean", example=true),
* @OA\Property(property="notes", type="string", nullable=true, example="표준 템플릿"),
* @OA\Property(property="items_count", type="integer", example=3, description="withCount(items) 사용 시"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time"),
* @OA\Property(property="deleted_at", type="string", format="date-time", nullable=true, example=null)
* )
*
* @OA\Schema(
* schema="BomTemplateItem",
* type="object",
* required={"id","tenant_id","bom_template_id","ref_type","ref_id","qty"},
* @OA\Property(property="id", type="integer", example=401),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="bom_template_id", type="integer", example=301),
* @OA\Property(property="ref_type", type="string", example="MATERIAL", enum={"MATERIAL","PRODUCT"}),
* @OA\Property(property="ref_id", type="integer", example=10101),
* @OA\Property(property="qty", type="number", format="double", example=2.000000),
* @OA\Property(property="waste_rate", type="number", format="double", example=0.000000),
* @OA\Property(property="uom_id", type="integer", nullable=true, example=null),
* @OA\Property(property="notes", type="string", nullable=true, example="프레임용"),
* @OA\Property(property="sort_order", type="integer", example=10),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="BomTemplateItemReplaceRequest",
* type="object",
* required={"items"},
* @OA\Property(
* property="items",
* type="array",
* @OA\Items(
* type="object",
* required={"ref_type","ref_id","qty"},
* @OA\Property(property="ref_type", type="string", enum={"MATERIAL","PRODUCT"}, example="MATERIAL"),
* @OA\Property(property="ref_id", type="integer", example=101),
* @OA\Property(property="qty", type="number", format="double", example=2),
* @OA\Property(property="waste_rate", type="number", format="double", nullable=true, example=0),
* @OA\Property(property="uom_id", type="integer", nullable=true, example=null),
* @OA\Property(property="notes", type="string", nullable=true, example="프레임용"),
* @OA\Property(property="sort_order", type="integer", example=10)
* )
* )
* )
*/
/* ================
* 모델 엔드포인트
* ================ */
class DesignModelApi
{
/**
* @OA\Get(
* path="/api/v1/design/models",
* tags={"Model"},
* summary="모델 목록",
* description="모델(설계 상위) 목록을 페이징으로 조회합니다.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="q", in="query", description="검색어(code/name/description like)", @OA\Schema(type="string")),
* @OA\Parameter(ref="#/components/parameters/Page"),
* @OA\Parameter(ref="#/components/parameters/Size"),
* @OA\Response(
* response=200, description="조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/DesignModelPagination"))
* }
* )
* ),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Post(
* path="/api/v1/design/models",
* tags={"Model"},
* summary="모델 생성",
* description="모델(설계 상위)을 생성합니다.",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* required={"code","name"},
* @OA\Property(property="code", type="string", maxLength=100, example="KSS01"),
* @OA\Property(property="name", type="string", maxLength=200, example="KSS 표준 모델"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=12),
* @OA\Property(property="lifecycle", type="string", nullable=true, example="ACTIVE"),
* @OA\Property(property="description", type="string", nullable=true, example="롤러 구조 표준 설계"),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* ),
* @OA\Response(response=200, description="등록 성공", @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/DesignModel"))
* })),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Get(
* path="/api/v1/design/models/{id}",
* tags={"Model"},
* summary="모델 상세",
* description="단일 모델을 조회합니다. (versions 포함 가능)",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="조회 성공", @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/DesignModel"))
* })),
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/design/models/{id}",
* tags={"Model"},
* summary="모델 수정",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="code", type="string", maxLength=100, example="KSS01"),
* @OA\Property(property="name", type="string", maxLength=200, example="KSS 표준 모델(개정)"),
* @OA\Property(property="category_id", type="integer", nullable=true, example=12),
* @OA\Property(property="lifecycle", type="string", nullable=true, example="ACTIVE"),
* @OA\Property(property="description", type="string", nullable=true, example="개정 메모"),
* @OA\Property(property="is_active", type="boolean", example=true)
* )
* ),
* @OA\Response(response=200, description="수정 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")) ,
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/design/models/{id}",
* tags={"Model"},
* summary="모델 삭제(soft)",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="삭제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
}
/* ==================
* 모델버전 엔드포인트
* ================== */
class ModelVersionApi
{
/**
* @OA\Get(
* path="/api/v1/design/models/{modelId}/versions",
* tags={"ModelVersion"},
* summary="모델의 버전 목록",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="modelId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="조회 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/ModelVersion")))
* })
* ),
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function listVersions() {}
/**
* @OA\Post(
* path="/api/v1/design/models/{modelId}/versions",
* tags={"ModelVersion"},
* summary="버전 DRAFT 생성",
* description="version_no 미지정 시 자동 증가",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="modelId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=false,
* @OA\JsonContent(
* @OA\Property(property="version_no", type="integer", nullable=true, example=1),
* @OA\Property(property="notes", type="string", nullable=true, example="초안 메모"),
* @OA\Property(property="effective_from", type="string", format="date-time", nullable=true),
* @OA\Property(property="effective_to", type="string", format="date-time", nullable=true)
* )
* ),
* @OA\Response(response=200, description="생성 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ModelVersion"))
* })
* ),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function createDraft() {}
/**
* @OA\Post(
* path="/api/v1/design/versions/{versionId}/release",
* tags={"ModelVersion"},
* summary="버전 RELEASED 전환",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="versionId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="전환 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/ModelVersion"))
* })
* ),
* @OA\Response(response=409, description="상태 충돌", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function release() {}
}
/* ===================
* BOM 템플릿 엔드포인트
* =================== */
class BomTemplateApi
{
/**
* @OA\Get(
* path="/api/v1/design/versions/{versionId}/bom-templates",
* tags={"BomTemplate"},
* summary="모델버전의 BOM 템플릿 목록",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="versionId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="조회 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/BomTemplate")))
* })
* ),
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function listByVersion() {}
/**
* @OA\Post(
* path="/api/v1/design/versions/{versionId}/bom-templates",
* tags={"BomTemplate"},
* summary="BOM 템플릿 upsert (name 기준)",
* description="is_primary=true 지정 시 동일 모델버전의 기존 대표 템플릿은 자동 해제",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="versionId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(
* required=false,
* @OA\JsonContent(
* @OA\Property(property="name", type="string", maxLength=100, example="Main"),
* @OA\Property(property="is_primary", type="boolean", example=true),
* @OA\Property(property="notes", type="string", nullable=true, example="표준 템플릿")
* )
* ),
* @OA\Response(response=200, description="저장 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/BomTemplate"))
* })
* ),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function upsertTemplate() {}
/**
* @OA\Get(
* path="/api/v1/design/bom-templates/{templateId}",
* tags={"BomTemplate"},
* summary="BOM 템플릿 상세 (항목 포함)",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="templateId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\Response(response=200, description="조회 성공",
* @OA\JsonContent(allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/BomTemplate"))
* })
* ),
* @OA\Response(response=404, description="없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/design/bom-templates/{templateId}/items",
* tags={"BomTemplate"},
* summary="BOM 항목 일괄 치환",
* description="기존 항목을 모두 삭제 후 본문 items로 재삽입",
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="templateId", in="path", required=true, @OA\Schema(type="integer")),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/BomTemplateItemReplaceRequest")),
* @OA\Response(response=200, description="저장 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function replaceItems() {}
}

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
// 설계 상위: models
public function up(): void {
Schema::create('models', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
$table->string('code', 100)->comment('모델코드(설계코드)');
$table->string('name', 200)->comment('모델명');
$table->unsignedBigInteger('category_id')->nullable()->comment('카테고리ID(참조용, FK 미설정)');
$table->string('lifecycle', 30)->nullable()->comment('PLANNING/ACTIVE/DEPRECATED 등');
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
$table->unique(['tenant_id', 'code'], 'uq_models_tenant_code');
$table->index(['tenant_id', 'is_active'], 'idx_models_tenant_active');
$table->index(['tenant_id', 'category_id'], 'idx_models_tenant_category');
});
}
public function down(): void {
Schema::dropIfExists('models');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
// 설계 버전: model_versions
public function up(): void {
Schema::create('model_versions', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
$table->unsignedBigInteger('model_id')->comment('모델ID');
$table->integer('version_no')->comment('버전번호(1..N)');
$table->string('status', 30)->default('DRAFT')->comment('DRAFT/RELEASED');
$table->dateTime('effective_from')->nullable();
$table->dateTime('effective_to')->nullable();
$table->text('notes')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
$table->unique(['model_id', 'version_no'], 'uq_model_versions_model_ver');
$table->index(['tenant_id', 'status'], 'idx_mv_tenant_status');
$table->index(['tenant_id', 'model_id'], 'idx_mv_tenant_model');
});
}
public function down(): void {
Schema::dropIfExists('model_versions');
}
};

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
// 설계용 BOM 템플릿: bom_templates
public function up(): void {
Schema::create('bom_templates', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
$table->unsignedBigInteger('model_version_id')->comment('모델버전ID');
$table->string('name', 100)->default('Main')->comment('템플릿명');
$table->boolean('is_primary')->default(true)->comment('대표 템플릿 여부');
$table->text('notes')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['model_version_id', 'name'], 'uq_bomtpl_mv_name');
$table->index(['tenant_id', 'model_version_id'], 'idx_bomtpl_tenant_mv');
$table->index(['tenant_id', 'is_primary'], 'idx_bomtpl_tenant_primary');
});
}
public function down(): void {
Schema::dropIfExists('bom_templates');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
// 설계용 BOM 항목: bom_template_items
public function up(): void {
Schema::create('bom_template_items', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('tenant_id')->comment('테넌트ID');
$table->unsignedBigInteger('bom_template_id')->comment('BOM템플릿ID');
$table->string('ref_type', 20)->comment('참조타입: MATERIAL|PRODUCT');
$table->unsignedBigInteger('ref_id')->comment('참조ID(materials.id 또는 products.id)');
$table->decimal('qty', 18, 6)->default(1)->comment('수량');
$table->decimal('waste_rate', 9, 6)->default(0)->comment('로스율');
$table->unsignedBigInteger('uom_id')->nullable()->comment('단위ID(참조용)');
$table->string('notes', 255)->nullable();
$table->integer('sort_order')->default(0);
$table->timestamps();
$table->index(['tenant_id', 'bom_template_id'], 'idx_bomtpl_items_tenant_tpl');
$table->index(['tenant_id', 'ref_type', 'ref_id'], 'idx_bomtpl_items_tenant_ref');
$table->index(['bom_template_id', 'sort_order'], 'idx_bomtpl_items_sort');
});
}
public function down(): void {
Schema::dropIfExists('bom_template_items');
}
};

View File

@@ -0,0 +1,224 @@
<!-- SAM RULES: include=../inc/header.php; base=/tenant; width=1280; js=jQuery+BS5 -->
<?php
// BOM 템플릿 편집기
$CURRENT_SECTION='item';
include '../inc/header.php';
$modelId = isset($_GET['model_id']) ? (int)$_GET['model_id'] : 0;
?>
<div class="container" style="max-width:1280px; margin-top:24px;">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h5 m-0">BOM 템플릿 편집기</h1>
<div class="text-muted small">모델 ID: <code id="modelIdView"><?php echo $modelId; ?></code> — 모델 버전 선택 후 구성 편집</div>
</div>
<div class="d-flex gap-2">
<a href="/tenant/item/model_list.php" class="btn btn-outline-secondary"><i class="bi bi-list-ul"></i> 목록</a>
<a href="/tenant/item/model_create.php?id=<?php echo $modelId; ?>" class="btn btn-outline-primary"><i class="bi bi-pencil"></i> 모델 수정</a>
<button class="btn btn-success" id="btnSave"><i class="bi bi-save"></i> 저장</button>
</div>
</div>
<div class="card mb-3">
<div class="card-body row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">모델 버전</label>
<select id="modelVersion" class="form-select">
<!-- API 연동 전 샘플 -->
<option value="v1">v1.0 (DRAFT)</option>
<option value="v1r">v1.0 (RELEASED)</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">그룹 추가</label>
<div class="input-group">
<input id="groupName" class="form-control" placeholder="예: 본체, 절곡물" />
<button id="btnAddGroup" class="btn btn-outline-primary"><i class="bi bi-plus-lg"></i></button>
</div>
</div>
<div class="col-md-6 text-end">
<div class="form-text">※ 그룹 = BOM 템플릿 내 상위 분류 (예: 본체/절곡물/모터/부자재)</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-4">
<div class="card h-100">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h2 class="h6 m-0">BOM 그룹 (템플릿)</h2>
<button id="btnExpand" class="btn btn-sm btn-outline-secondary">모두 펼치기</button>
</div>
<div class="card-body" style="max-height:520px; overflow:auto;">
<ul class="list-group" id="groupList">
<!-- JS 렌더링: 그룹/하위아이템 -->
</ul>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card h-100">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h2 class="h6 m-0">아이템 상세</h2>
<div class="d-flex gap-2">
<button id="btnAddItem" class="btn btn-sm btn-outline-primary"><i class="bi bi-plus-lg"></i> 아이템 추가</button>
<button id="btnDelNode" class="btn btn-sm btn-outline-danger"><i class="bi bi-trash"></i> 선택 삭제</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table align-middle" id="itemTable">
<thead class="table-light">
<tr>
<th style="width:160px;">구분</th>
<th style="width:160px;">참조코드</th>
<th>명칭</th>
<th style="width:120px;">수량</th>
<th style="width:140px;">옵션/조건</th>
<th style="width:80px;" class="text-center">삭제</th>
</tr>
</thead>
<tbody>
<!-- JS 렌더링 -->
</tbody>
</table>
</div>
<div class="form-text">
※ 구분: PRODUCT(서브모델) / MATERIAL(자재) / PART(부품). 옵션/조건은 견적-선택값과 매핑(예: 가이드레일=벽면형).
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function(){
const modelId = Number($('#modelIdView').text()) || 0;
// 샘플 데이터: 그룹과 아이템
let GROUPS = [
{id:'g1', name:'본체', items:[{id:'i1', type:'MATERIAL', ref:'SILICA-SET', name:'실리카원단+내화실 세트', qty:1, cond:'-'}, {id:'i2', type:'PRODUCT', ref:'SLAT-JOINT-SET', name:'슬랫+조인트바 세트', qty:1, cond:'-'}]},
{id:'g2', name:'절곡물', items:[{id:'i3', type:'PRODUCT', ref:'GUIDE-WALL-C', name:'가이드레일(벽면형/C형)', qty:1, cond:'옵션: 벽면형'}, {id:'i4', type:'PRODUCT', ref:'GUIDE-SIDE-D', name:'가이드레일(측면형/D형)', qty:1, cond:'옵션: 측면형'}]},
{id:'g3', name:'모터', items:[{id:'i5', type:'PART', ref:'MOTOR-SET', name:'모터+브라켓', qty:1, cond:'-'}]},
{id:'g4', name:'부자재', items:[{id:'i6', type:'PART', ref:'SHAFT-2IN', name:'감기샤프트 2인치', qty:1, cond:'-'}, {id:'i7', type:'PART', ref:'BOX-PIPE', name:'각파이프', qty:2, cond:'-'}]},
];
// 그룹 렌더링
function renderGroups(){
const $ul = $('#groupList').empty();
GROUPS.forEach(g=>{
const $li = $(`<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="fw-semibold">${g.name}</div>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary btn-rename" data-id="${g.id}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-outline-danger btn-del-group" data-id="${g.id}"><i class="bi bi-trash"></i></button>
</div>
</div>
</li>`);
$li.on('click', function(){ selectGroup(g.id); });
$ul.append($li);
});
}
let currentGroupId = null;
function selectGroup(id){
currentGroupId = id;
const g = GROUPS.find(x=>x.id===id);
renderItems(g ? g.items : []);
$('#groupList .list-group-item').removeClass('active');
$('#groupList .list-group-item').filter(function(){ return $(this).text().trim().startsWith(g.name); }).addClass('active');
}
function renderItems(items){
const $tb = $('#itemTable tbody').empty();
items.forEach(it=>{
const $tr = $(`<tr data-id="${it.id}">
<td>
<select class="form-select form-select-sm type">
<option ${it.type==='PRODUCT'?'selected':''}>PRODUCT</option>
<option ${it.type==='PART'?'selected':''}>PART</option>
<option ${it.type==='MATERIAL'?'selected':''}>MATERIAL</option>
</select>
</td>
<td><input class="form-control form-control-sm ref" value="${it.ref}"></td>
<td><input class="form-control form-control-sm name" value="${it.name}"></td>
<td style="width:120px;"><input type="number" step="0.001" class="form-control form-control-sm qty" value="${it.qty}"></td>
<td><input class="form-control form-control-sm cond" placeholder="예: 옵션=벽면형" value="${it.cond||''}"></td>
<td class="text-center"><button class="btn btn-sm btn-outline-danger btn-del-item"><i class="bi bi-x-lg"></i></button></td>
</tr>`);
$tb.append($tr);
});
}
// 이벤트 바인딩
$('#btnAddGroup').on('click', function(e){
e.preventDefault();
const name = ($('#groupName').val()||'').trim();
if(!name) return alert('그룹명을 입력하세요.');
const id = 'g'+(Date.now());
GROUPS.push({id, name, items:[]});
$('#groupName').val('');
renderGroups();
});
$('#groupList').on('click', '.btn-del-group', function(e){
e.stopPropagation();
const id = $(this).data('id');
if(!confirm('그룹을 삭제할까요? 하위 아이템도 함께 삭제됩니다.')) return;
GROUPS = GROUPS.filter(g=>g.id!==id);
currentGroupId = null;
renderGroups();
renderItems([]);
});
$('#groupList').on('click', '.btn-rename', function(e){
e.stopPropagation();
const id = $(this).data('id');
const g = GROUPS.find(x=>x.id===id);
const name = prompt('새 그룹명', g?.name||'');
if(name){ g.name = name; renderGroups(); }
});
$('#btnAddItem').on('click', function(){
if(!currentGroupId) return alert('먼저 그룹을 선택하세요.');
const g = GROUPS.find(x=>x.id===currentGroupId);
g.items.push({id:'i'+Date.now(), type:'PART', ref:'', name:'', qty:1, cond:''});
renderItems(g.items);
});
$('#itemTable').on('click', '.btn-del-item', function(){
const $tr = $(this).closest('tr');
const id = $tr.data('id');
const g = GROUPS.find(x=>x.id===currentGroupId);
g.items = g.items.filter(x=>x.id!==id);
renderItems(g.items);
});
// 저장 (템플릿 -> items payload)
$('#btnSave').on('click', function(){
// collect
const payload = {
model_id: modelId,
version: $('#modelVersion').val(),
groups: GROUPS.map(g=>({
name: g.name,
items: g.items.map(it=>({ type: $('.type', `tr[data-id="${it.id}"]`).val() || it.type,
ref: $('.ref', `tr[data-id="${it.id}"]`).val() || it.ref,
name: $('.name',`tr[data-id="${it.id}"]`).val() || it.name,
qty: Number($('.qty', `tr[data-id="${it.id}"]`).val() || it.qty),
cond: $('.cond',`tr[data-id="${it.id}"]`).val() || it.cond }))
}))
};
console.log('BOM TEMPLATE SAVE', payload);
alert('샘플: 콘솔에 저장 payload 출력. API 연동 필요');
});
// 초기 렌더
renderGroups();
if(GROUPS.length) selectGroup(GROUPS[0].id);
})();
</script>
<?php include '../inc/footer.php'; ?>

View File

@@ -0,0 +1,321 @@
<?php
// 모델관리
$CURRENT_SECTION='item';
include '../inc/header.php';
?>
<!--
SAM RULES (summary)
1) Prototype uses Bootstrap 5 + jQuery; no server changes.
2) Layout max-width 1280px; brand blue #2c4a85.
3) Keep scope to *Model metadata only* (NO BOM, NO attributes).
4) All paths placeholder; wire Ajax later.
5) Comment banner for consistency across files.
-->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>모델 등록 | SAM Prototype (Model-only)</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet" />
<style>
:root{ --brand:#2c4a85; }
body{ background:#f7f9fc; }
.container{ max-width:1280px; }
.card{ border-radius:1rem; box-shadow:0 6px 20px rgba(0,0,0,.05); }
.page-title{ display:flex; gap:.75rem; align-items:center; }
.page-title .badge{ background:var(--brand); }
.required::after{ content:"*"; color:#dc3545; margin-left:4px; }
.tag-input{ min-height:38px; border:1px solid #ced4da; border-radius:.5rem; padding:.25rem .5rem; background:#fff; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap; }
.tag-chip{ display:inline-flex; align-items:center; gap:.25rem; padding:.25rem .5rem; border-radius:20px; background:#edf2ff; }
.tag-chip .remove{ cursor:pointer; }
.dropzone{ border:2px dashed #cdd7e1; border-radius:.75rem; padding:16px; text-align:center; background:#fff; cursor:pointer; }
.sticky-actions{ position:sticky; bottom:0; background:rgba(255,255,255,.95); backdrop-filter:saturate(120%) blur(4px); border-top:1px solid #e5e7eb; }
</style>
</head>
<body>
<header class="py-3 mb-3 border-bottom bg-white">
<div class="container d-flex justify-content-between align-items-center">
<div class="page-title">
<a class="btn btn-outline-secondary" href="#" onclick="history.back();return false;"><i class="bi bi-arrow-left"></i></a>
<div>
<h1 class="h4 m-0">모델 등록</h1>
<div class="text-muted">모델(설계 뼈대) 메타데이터만 입력합니다. ※ BOM/속성은 별도 화면</div>
</div>
<span class="badge text-bg-primary ms-2">MODEL-ONLY</span>
</div>
<div>
<a class="btn btn-outline-primary" href="#"><i class="bi bi-list-ul me-1"></i>목록</a>
</div>
</div>
</header>
<main class="container pb-5">
<form id="modelForm" class="needs-validation" novalidate>
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header bg-white">
<div class="d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">기본정보</h2>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="isActive" checked>
<label class="form-check-label" for="isActive">활성화</label>
</div>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label required" for="code">모델 코드</label>
<input type="text" class="form-control" id="code" placeholder="예: KSS01" required>
<div class="invalid-feedback">모델 코드를 입력하세요.</div>
</div>
<div class="col-md-8">
<label class="form-label required" for="name">모델명</label>
<input type="text" class="form-control" id="name" placeholder="예: 방화셔터 KSS01" required>
<div class="invalid-feedback">모델명을 입력하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label required" for="unit">기본 단위</label>
<select id="unit" class="form-select" required>
<option value="">선택...</option>
<option>EA</option>
<option>SET</option>
<option>M</option>
<option>KG</option>
</select>
<div class="invalid-feedback">기본 단위를 선택하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label" for="tagsInput">태그</label>
<div id="tagsInput" class="tag-input" role="group" aria-label="tags">
<input id="tagText" type="text" class="form-control border-0 p-0" placeholder="Enter로 태그 추가 (예: 본체, 절곡물, BLDC)" />
</div>
</div>
<div class="col-12">
<label class="form-label required" for="categoryName">카테고리</label>
<div class="input-group">
<input type="text" id="categoryName" class="form-control" placeholder="카테고리를 선택하세요" readonly required>
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#categoryModal"><i class="bi bi-diagram-3"></i> 선택</button>
</div>
<input type="hidden" id="categoryId" />
<div class="form-text">※ 모델이 속하는 분류(예: 제품>방화셔터>KSS) — 구성(BOM)과는 별개</div>
<div class="invalid-feedback">카테고리를 선택하세요.</div>
</div>
<div class="col-12">
<label class="form-label" for="description">설명</label>
<textarea id="description" rows="3" class="form-control" placeholder="모델 개요/용도 등을 입력"></textarea>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header bg-white d-flex align-items-center justify-content-between">
<h2 class="h6 m-0">이미지</h2>
<div class="text-muted small">대표 이미지 1장</div>
</div>
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-md-6">
<div id="imageDrop" class="dropzone">여기로 드래그하거나 클릭하여 선택</div>
<input type="file" id="imageFile" accept="image/*" class="form-control mt-2" />
</div>
<div class="col-md-6">
<div id="previewImg" class="bg-light border rounded d-flex align-items-center justify-content-center" style="width:120px;height:120px;">
<i class="bi bi-image fs-3 text-muted"></i>
</div>
</div>
</div>
</div>
</div>
<div class="sticky-actions mt-4 p-3 d-flex gap-2 justify-content-end bg-white">
<button type="button" class="btn btn-outline-secondary" id="btnReset"><i class="bi bi-arrow-counterclockwise"></i> 초기화</button>
<button type="button" class="btn btn-outline-primary" id="btnDraft"><i class="bi bi-cloud-arrow-up"></i> 임시저장</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check2-circle"></i> 등록</button>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-white">
<h2 class="h6 m-0">미리보기</h2>
</div>
<div class="card-body">
<div class="d-flex align-items-center gap-3 mb-3">
<div id="pvImg" class="bg-light border rounded" style="width:72px;height:72px; display:flex; align-items:center; justify-content:center;">
<i class="bi bi-image fs-3 text-muted"></i>
</div>
<div>
<div class="text-muted small" id="pvCode">CODE</div>
<div class="fw-bold" id="pvName">모델명 미입력</div>
<div class="small text-muted" id="pvCategory">카테고리 미선택</div>
</div>
</div>
<div id="pvTags" class="d-flex flex-wrap gap-1"></div>
<hr>
<div class="small text-muted">※ 이 화면은 모델 메타데이터만 다룹니다. BOM 구성은 별도의 “BOM 템플릿 편집기”에서 작성하세요.</div>
</div>
</div>
</div>
</div>
</form>
</main>
<!-- Category Modal (sample tree) -->
<div class="modal fade" id="categoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-diagram-3 me-2"></i>카테고리 선택</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-5">
<input id="catSearch" type="search" class="form-control" placeholder="카테고리 검색" />
<div class="border rounded p-2 mt-2" style="max-height:320px; overflow:auto;">
<ul class="list-unstyled" id="catTree">
<li>
<label class="d-block"><input type="radio" name="cat" value="100" data-name="제품/방화셔터/KSS" class="form-check-input me-2"> 제품</label>
<ul class="ms-4 mt-1">
<li>
<label class="d-block"><input type="radio" name="cat" value="110" data-name="제품/방화셔터" class="form-check-input me-2"> 방화셔터</label>
<ul class="ms-4 mt-1">
<li><label class="d-block"><input type="radio" name="cat" value="111" data-name="제품/방화셔터/KSS" class="form-check-input me-2"> KSS</label></li>
</ul>
</li>
</ul>
</li>
</ul>
</div>
</div>
<div class="col-md-7">
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i> 최종(리프) 카테고리를 선택하세요. 이 선택은 모델의 *분류*만 의미합니다.
</div>
<div>
<div class="text-muted small">선택된 카테고리</div>
<div id="catPicked" class="fw-semibold">-</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="btnPickCategory"><i class="bi bi-check2"></i> 선택</button>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:1080;">
<div id="saveToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<i class="bi bi-check2-circle me-2 text-success"></i>
<strong class="me-auto">저장 완료</strong>
<small>지금</small>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">모델 메타데이터가 임시로 저장되었습니다. (콘솔 로그 확인)</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const toast = new bootstrap.Toast(document.getElementById('saveToast'));
function collectFormData(){
const tags = [];
$('#pvTags .tag-chip').each(function(){ tags.push($(this).data('tag')); });
return {
code: $('#code').val().trim(),
name: $('#name').val().trim(),
unit: $('#unit').val(),
is_active: $('#isActive').is(':checked') ? 1 : 0,
category_id: $('#categoryId').val() || null,
category_path: $('#categoryName').val() || null,
description: $('#description').val().trim(),
tags
};
}
function updatePreview(){
$('#pvCode').text($('#code').val() || 'CODE');
$('#pvName').text($('#name').val() || '모델명 미입력');
$('#pvCategory').text($('#categoryName').val() || '카테고리 미선택');
}
function addTag(text){
const tag = text.trim();
if(!tag) return;
const dup = $('#pvTags .tag-chip').filter(function(){return $(this).data('tag')===tag;}).length>0;
if(dup) return;
const chip = $(`<span class="tag-chip" data-tag="${tag}"><i class="bi bi-hash"></i>${tag}<i class="bi bi-x-lg remove"></i></span>`);
$('#pvTags').append(chip);
$('#tagText').val('');
}
$(function(){
// live preview
$('#code, #name, #categoryName').on('input change', updatePreview);
$('#code').on('input', function(){ this.value = this.value.toUpperCase(); });
// tags
$('#tagText').on('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); addTag(this.value); } });
$('#pvTags').on('click', '.remove', function(){ $(this).closest('.tag-chip').remove(); });
// category search
$('#catSearch').on('input', function(){
const q = this.value.trim().toLowerCase();
$('#catTree li').each(function(){ $(this).toggle($(this).text().toLowerCase().includes(q)); });
});
// category pick
$('input[name="cat"]').on('change', function(){ $('#catPicked').text($(this).data('name')); });
$('#btnPickCategory').on('click', function(){
const $sel = $('input[name="cat"]:checked');
if($sel.length===0){ alert('카테고리를 선택하세요.'); return; }
$('#categoryId').val($sel.val());
$('#categoryName').val($sel.data('name'));
updatePreview();
bootstrap.Modal.getInstance(document.getElementById('categoryModal')).hide();
});
// image preview + drop
$('#imageDrop').on('click', ()=> $('#imageFile').trigger('click'));
$('#imageDrop').on('dragover', e=>{ e.preventDefault(); $(e.currentTarget).addClass('border-primary'); });
$('#imageDrop').on('dragleave drop', e=>{ e.preventDefault(); $(e.currentTarget).removeClass('border-primary'); if(e.type==='drop'){ const f=e.originalEvent.dataTransfer.files; if(f && f[0]){ $('#imageFile')[0].files=f; $('#imageFile').trigger('change'); } } });
$('#imageFile').on('change', function(){
const file = this.files && this.files[0]; if(!file) return;
const reader = new FileReader();
reader.onload = e => { $('#previewImg, #pvImg').html(`<img src="${e.target.result}" alt="img" class="img-fluid rounded" style="max-width:100%; max-height:100%;">`); };
reader.readAsDataURL(file);
});
// submit
const form = document.getElementById('modelForm');
form.addEventListener('submit', function(ev){
ev.preventDefault(); ev.stopPropagation();
if(!form.checkValidity()) { form.classList.add('was-validated'); return; }
const payload = collectFormData();
console.log('MODEL:CREATE payload', payload);
toast.show();
});
// draft + reset
$('#btnDraft').on('click', function(){ console.log('MODEL:DRAFT payload', collectFormData()); toast.show(); });
$('#btnReset').on('click', function(){ form.reset(); $('#pvTags').empty(); updatePreview(); $('#previewImg, #pvImg').html('<i class="bi bi-image fs-3 text-muted"></i>'); });
updatePreview();
});
</script>
</body>
</html>
<?php include '../inc/footer.php'; ?>

View File

@@ -4,307 +4,179 @@
include '../inc/header.php';
?>
<div class="container" style="max-width:1280px; margin-top:24px;">
<!-- 탭 -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link" href="/tenant/material/list.php">자재 관리</a></li>
<li class="nav-item"><a class="nav-link active" href="/tenant/product/model_list.php">모델 관리</a></li>
<li class="nav-item"><a class="nav-link" href="/tenant/product/bom_editor.php">BOM 관리</a></li>
</ul>
<!-- 툴바 -->
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<div class="d-flex align-items-center gap-2">
<label class="text-muted">분류</label>
<select class="form-select form-select-sm" id="filterCategory" style="width:160px;">
<option value="">전체</option>
<option value="스크린">스크린</option>
<option value="철재">철재</option>
<option value="유리">유리</option>
</select>
</div>
<div class="input-group" style="max-width:340px;">
<input type="text" class="form-control form-control-sm" id="keyword" placeholder="모델명/로트코드 검색">
<button class="btn btn-outline-secondary btn-sm" id="btnSearch">Search</button>
</div>
<div class="d-flex align-items-center gap-2">
<label class="text-muted">표시</label>
<select id="pageSize" class="form-select form-select-sm" style="width:80px;">
<option>10</option><option selected>20</option><option>30</option><option>50</option>
</select>
</div>
<div class="ms-auto small text-muted" id="totalInfo">총 0건</div>
<button class="btn btn-primary btn-sm" id="btnOpenCreate">신규등록</button>
</div>
<!-- 리스트 -->
<div class="table-responsive border rounded">
<table class="table table-hover table-striped align-middle m-0">
<thead class="table-light">
<tr>
<th style="width:70px;">NO</th>
<th style="width:140px;">분류</th>
<th>모델명</th>
<th style="width:140px;">로트 코드</th>
<th style="width:140px;">등록일</th>
<th style="width:160px;" class="text-center">수정/삭제</th>
</tr>
</thead>
<tbody id="listBody"></tbody>
</table>
</div>
<!-- 페이징 -->
<div class="d-flex justify-content-center mt-3">
<nav id="pagerNav" aria-label="Page navigation"></nav>
</div>
</div>
<!-- 모달 (부트스트랩 + 폴백) -->
<div class="modal fade" id="modelModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<form class="modal-content needs-validation" id="modelForm" novalidate>
<div class="modal-header">
<h5 class="modal-title" id="modelModalTitle">모델 등록</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="닫기" id="btnCloseX"></button>
<form id="modelForm" class="needs-validation" novalidate>
<div class="row g-4">
<!-- LEFT: form fields -->
<div class="col-lg-8">
<div class="card">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h2 class="h6 m-0">모델 기본정보</h2>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="isActive" checked>
<label class="form-check-label" for="isActive">활성화</label>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label required" for="code">모델 코드</label>
<input type="text" class="form-control" id="code" placeholder="예: MDL-2025-001" required>
<div class="invalid-feedback">모델 코드를 입력하세요.</div>
</div>
<div class="col-md-8">
<label class="form-label required" for="name">모델명</label>
<input type="text" class="form-control" id="name" placeholder="예: 컨베이어 모듈 A" required>
<div class="invalid-feedback">모델명을 입력하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label required" for="unit">기본 단위</label>
<select id="unit" class="form-select" required>
<option value="">선택...</option>
<option>EA</option>
<option>SET</option>
<option>M</option>
<option>KG</option>
<option>BOX</option>
</select>
<div class="invalid-feedback">기본 단위를 선택하세요.</div>
</div>
<div class="col-md-6">
<label class="form-label required" for="categoryName">카테고리</label>
<div class="input-group">
<input type="text" id="categoryName" class="form-control" placeholder="카테고리를 선택하세요" readonly required>
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#categoryModal"><i class="bi bi-diagram-3"></i> 선택</button>
</div>
<input type="hidden" id="categoryId" />
<div class="invalid-feedback">카테고리를 선택하세요.</div>
</div>
<div class="col-12">
<label class="form-label" for="description">설명</label>
<textarea id="description" rows="3" class="form-control" placeholder="모델 설명을 입력"></textarea>
</div>
<div class="col-12">
<label class="form-label" for="tagsInput">태그</label>
<div id="tagsInput" class="tag-input">
<input id="tagText" type="text" class="form-control border-0 p-0" placeholder="Enter로 태그 추가" />
</div>
</div>
<div class="col-12">
<label class="form-label">대표 이미지</label>
<div id="imageDrop" class="dropzone">클릭 또는 드래그하여 선택</div>
<input type="file" id="imageFile" accept="image/*" class="form-control mt-2" />
</div>
</div>
</div>
</div>
<div class="mt-4 d-flex justify-content-end gap-2">
<button type="reset" class="btn btn-outline-secondary"><i class="bi bi-arrow-counterclockwise"></i> 초기화</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-check2-circle"></i> 등록</button>
</div>
</div>
<div class="modal-body">
<input type="hidden" id="mdlId">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">분류 <span class="text-danger">*</span></label>
<select class="form-select" id="mdlCategory" required>
<option value="">선택</option>
<option>스크린</option>
<option>철재</option>
<option>유리</option>
</select>
<div class="invalid-feedback">분류를 선택하세요.</div>
<!-- RIGHT: preview -->
<div class="col-lg-4">
<div class="card preview-card">
<div class="card-header bg-white">
<h2 class="h6 m-0">미리보기</h2>
</div>
<div class="col-md-5">
<label class="form-label">모델명 <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="mdlName" required>
<div class="invalid-feedback">모델명을 입력하세요.</div>
</div>
<div class="col-md-4">
<label class="form-label">로트 코드</label>
<input type="text" class="form-control" id="mdlLot" placeholder="예: SA, WE">
</div>
<div class="col-12">
<label class="form-label">내용</label>
<input type="text" class="form-control" id="mdlDesc" placeholder="간단 설명">
<div class="card-body">
<div class="d-flex align-items-center gap-3 mb-3">
<div id="previewImg" class="bg-light border rounded" style="width:72px;height:72px; display:flex; align-items:center; justify-content:center;">
<i class="bi bi-image fs-3 text-muted"></i>
</div>
<div>
<div class="text-muted small" id="pvCode">CODE</div>
<div class="fw-bold" id="pvName">모델명 미입력</div>
<div class="small text-muted" id="pvCategory">카테고리 미선택</div>
</div>
</div>
<div id="pvTags" class="d-flex flex-wrap gap-1 mb-3"></div>
</div>
</div>
</div>
<div class="modal-footer">
<div class="me-auto small text-muted" id="modalHint"></div>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="btnClose">닫기</button>
<button type="submit" class="btn btn-primary" id="btnSubmit">등록</button>
</div>
</form>
</div>
</div>
</form>
</div>
<!-- 토스트 -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:1080">
<div id="snack" class="toast align-items-center text-bg-primary border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="snackMsg">Saved</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
<!-- Category Modal -->
<div class="modal fade" id="categoryModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-diagram-3 me-2"></i>카테고리 선택</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="list-unstyled" id="catTree">
<li><label><input type="radio" name="cat" value="101" data-name="제품/모터" class="form-check-input me-2"> 제품 > 모터</label></li>
<li><label><input type="radio" name="cat" value="201" data-name="제품/컨베이어" class="form-check-input me-2"> 제품 > 컨베이어</label></li>
<li><label><input type="radio" name="cat" value="301" data-name="반제품/프레임" class="form-check-input me-2"> 반제품 > 프레임</label></li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">취소</button>
<button type="button" class="btn btn-primary" id="btnPickCategory">선택</button>
</div>
</div>
</div>
</div>
<style>
.table > :not(caption) > * > * { vertical-align: middle; }
/* 부트스트랩 JS 없는 경우의 간단 폴백 */
.modal.fallback-show { display:block; background:rgba(0,0,0,.45); }
.modal.fallback-show .modal-dialog {
top: 50%;
transform: translateY(-50%);
margin: 0 auto;
position: relative;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function(){
// ===== 샘플 데이터 =====
function buildSample(count=64){
const cats=['스크린','철재','유리'], lots=['SS','SA','SE','WE','TS','TE','DS'];
const arr=[]; for(let i=1;i<=count;i++){
arr.push({
id:i,
category: cats[i%cats.length],
name: (i%7===0)? `${cats[i%cats.length]} 비인정` : `${cats[i%cats.length]} K${String(i).padStart(2,'0')}`,
lot: lots[i%lots.length],
created_at: new Date(Date.now()-86400000*i).toISOString().slice(0,10),
desc:''
});
} return arr;
$(function(){
function updatePreview(){
$('#pvCode').text($('#code').val() || 'CODE');
$('#pvName').text($('#name').val() || '모델명 미입력');
$('#pvCategory').text($('#categoryName').val() || '카테고리 미선택');
}
let MODELS = buildSample(97);
$('#code,#name,#categoryName').on('input change', updatePreview);
// ===== 상태/유틸 =====
let PAGE=1, SIZE=20;
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
const hasBS = ()=> !!window.bootstrap;
const snack = ()=> hasBS() ? new bootstrap.Toast('#snack') : { show(){ /* no-op */ } };
function getFiltered(){
const cat=$('#filterCategory').value.trim();
const kw=$('#keyword').value.trim().toLowerCase();
return MODELS.filter(m=>{
const okCat=!cat || m.category===cat;
const hay=(m.name+' '+(m.lot||'')).toLowerCase();
const okKw=!kw || hay.includes(kw);
return okCat && okKw;
});
}
// ===== 렌더 =====
function renderList(){
const data=getFiltered();
const total=data.length;
const pages=Math.max(1, Math.ceil(total/SIZE));
if(PAGE>pages) PAGE=pages;
const start=(PAGE-1)*SIZE, end=start+SIZE;
$('#listBody').innerHTML = data.slice(start,end).map((m,i)=>`
<tr data-id="${m.id}">
<td>${start+i+1}</td>
<td>${m.category}</td>
<td>${m.name}</td>
<td>${m.lot||''}</td>
<td>${m.created_at}</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary me-1 btnEdit">수정</button>
<button class="btn btn-sm btn-outline-danger btnDel">삭제</button>
</td>
</tr>
`).join('') || `<tr><td colspan="6" class="text-center text-muted py-4">데이터가 없습니다.</td></tr>`;
$('#totalInfo').textContent=`총 ${total.toLocaleString()}건 · ${total?(start+1):0}-${Math.min(end,total)} 표시`;
const win=7; let sp=Math.max(1,PAGE-Math.floor(win/2)), ep=Math.min(pages, sp+win-1); sp=Math.max(1,ep-win+1);
let html=`<ul class="pagination pagination-sm m-0">`;
html+=`<li class="page-item ${PAGE===1?'disabled':''}"><a class="page-link" href="#" data-go="first">«</a></li>`;
html+=`<li class="page-item ${PAGE===1?'disabled':''}"><a class="page-link" href="#" data-go="${PAGE-1}"></a></li>`;
for(let p=sp;p<=ep;p++){ html+=`<li class="page-item ${p===PAGE?'active':''}"><a class="page-link" href="#" data-go="${p}">${p}</a></li>`; }
html+=`<li class="page-item ${PAGE===pages?'disabled':''}"><a class="page-link" href="#" data-go="${PAGE+1}"></a></li>`;
html+=`<li class="page-item ${PAGE===pages?'disabled':''}"><a class="page-link" href="#" data-go="last">»</a></li>`;
html+=`</ul>`; $('#pagerNav').innerHTML=html;
}
// ===== 모달(부트스트랩/폴백) =====
let mdlModal = null;
function openModal(){
try{
if (hasBS()){
mdlModal = mdlModal || new bootstrap.Modal('#modelModal');
mdlModal.show(); return;
$('#tagText').on('keydown', function(e){
if(e.key==='Enter'){
e.preventDefault();
const tag=this.value.trim();
if(tag){
const chip=$(`<span class="tag-chip" data-tag="${tag}"><i class="bi bi-hash"></i>${tag}<i class="bi bi-x-lg remove"></i></span>`);
$('#pvTags').append(chip);
this.value='';
}
}catch(_){}
// 폴백
const m = $('#modelModal');
m.classList.add('fallback-show','show');
}
function closeModal(){
if (hasBS() && mdlModal){ mdlModal.hide(); return; }
const m = $('#modelModal');
m.classList.remove('fallback-show','show');
}
$('#btnClose').addEventListener('click', closeModal);
$('#btnCloseX').addEventListener('click', closeModal);
}
});
$('#pvTags').on('click','.remove',function(){ $(this).parent().remove(); });
// ===== 이벤트 =====
// 신규등록
$('#btnOpenCreate').addEventListener('click', ()=>{
$('#modelModalTitle').textContent='모델 등록';
$('#btnSubmit').textContent='등록';
$('#modelForm').reset();
$('#mdlId').value='';
$('#modalHint').textContent='※ 필수 입력: 분류, 모델명';
openModal();
$('input[name="cat"]').on('change', function(){
$('#categoryId').val(this.value);
$('#categoryName').val($(this).data('name')).trigger('change');
});
// 저장(등록/수정)
$('#modelForm').addEventListener('submit', e=>{
e.preventDefault(); e.stopPropagation();
e.currentTarget.classList.add('was-validated');
if(!e.currentTarget.checkValidity()) return;
$('#imageFile').on('change', function(){
const file=this.files[0];
if(file){
const reader=new FileReader();
reader.onload=e=>$('#previewImg').html(`<img src="${e.target.result}" class="img-fluid rounded" style="max-width:72px; max-height:72px;">`);
reader.readAsDataURL(file);
}
});
const dto={
id: $('#mdlId').value ? parseInt($('#mdlId').value,10) : null,
category: $('#mdlCategory').value,
name: $('#mdlName').value.trim(),
lot: $('#mdlLot').value.trim(),
desc: $('#mdlDesc').value.trim(),
created_at: new Date().toISOString().slice(0,10)
$('#imageDrop').on('click', ()=> $('#imageFile').trigger('click'));
$('#modelForm').on('submit', function(e){
e.preventDefault();
if(!this.checkValidity()){ this.classList.add('was-validated'); return; }
const payload={
code:$('#code').val(),
name:$('#name').val(),
unit:$('#unit').val(),
is_active:$('#isActive').is(':checked')?1:0,
category_id:$('#categoryId').val(),
description:$('#description').val(),
tags:$('#pvTags .tag-chip').map((i,e)=>$(e).data('tag')).get()
};
if(dto.id){
const idx=MODELS.findIndex(x=>x.id===dto.id);
if(idx>=0) MODELS[idx]={...MODELS[idx], ...dto};
$('#snackMsg').textContent='수정되었습니다.'; snack().show();
}else{
dto.id=(MODELS.reduce((mx,m)=>Math.max(mx,m.id),0)||0)+1;
MODELS.unshift(dto);
$('#snackMsg').textContent='등록되었습니다.'; snack().show();
}
closeModal();
PAGE=1; renderList();
console.log('SAVE',payload);
alert('모델 등록 데이터 콘솔 확인');
});
// 수정/삭제/페이징
document.addEventListener('click', e=>{
const a=e.target.closest('#pagerNav a');
if (a){ e.preventDefault();
const pages=Math.max(1, Math.ceil(getFiltered().length/SIZE));
const go=a.dataset.go;
if(go==='first') PAGE=1;
else if(go==='last') PAGE=pages;
else PAGE=Math.min(Math.max(parseInt(go,10)||1,1), pages);
renderList();
return;
}
if (e.target.classList.contains('btnEdit')){
const id=parseInt(e.target.closest('tr').dataset.id,10);
const m=MODELS.find(x=>x.id===id); if(!m) return;
$('#modelModalTitle').textContent='모델 수정';
$('#btnSubmit').textContent='수정';
$('#mdlId').value=m.id; $('#mdlCategory').value=m.category; $('#mdlName').value=m.name;
$('#mdlLot').value=m.lot||''; $('#mdlDesc').value=m.desc||'';
$('#modalHint').textContent='필드 수정 후 [수정] 클릭';
openModal();
}
if (e.target.classList.contains('btnDel')){
const id=parseInt(e.target.closest('tr').dataset.id,10);
if(!confirm('삭제하시겠습니까?')) return;
MODELS = MODELS.filter(x=>x.id!==id);
$('#snackMsg').textContent='삭제되었습니다.'; snack().show();
renderList();
}
});
// 검색/필터/표시개수
$('#btnSearch').addEventListener('click', ()=>{ PAGE=1; renderList(); });
$('#keyword').addEventListener('keydown', e=>{ if(e.key==='Enter'){ PAGE=1; renderList(); }});
$('#filterCategory').addEventListener('change', ()=>{ PAGE=1; renderList(); });
$('#pageSize').addEventListener('change', function(){ SIZE=parseInt(this.value,10)||20; PAGE=1; renderList(); });
// 최초
document.addEventListener('DOMContentLoaded', ()=>{
const ps=$('#pageSize'); if(ps) SIZE=parseInt(ps.value,10)||20;
renderList();
});
})();
});
</script>
<!-- Bootstrap JS (있으면 사용, 없어도 폴백으로 동작) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<?php include '../inc/footer.php'; ?>

View File

@@ -29,6 +29,12 @@
use App\Http\Controllers\Api\V1\CategoryTemplateController;
use App\Http\Controllers\Api\V1\ClassificationController;
// 설계 전용 (디자인 네임스페이스)
use App\Http\Controllers\Api\V1\Design\DesignModelController as DesignModelController;
use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController;
use App\Http\Controllers\Api\V1\Design\BomTemplateController as DesignBomTemplateController;
// error test
Route::get('/test-error', function () {
throw new \Exception('슬랙 전송 테스트 예외');
@@ -121,9 +127,9 @@
// Material, Model, BOM API
Route::resource('materials', MaterialController::class)->except(['v1.create', 'edit']); // 자재관리
Route::resource('materials', MaterialController::class)->except(['create', 'edit']); // 자재관리
Route::resource('models', ModelController::class)->except(['v1.create', 'edit']); // 모델관리
Route::resource('boms', BomController::class)->except(['v1.create', 'edit']); // BOM관리
Route::resource('boms', BomController::class)->except(['create', 'edit']); // BOM관리
// Menu API
@@ -189,9 +195,9 @@
// Permission API
Route::prefix('permissions')->group(function () {
Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('permissions.deptMenuMatrix');; // 부서별 권한 메트릭스
Route::get('roles/{role_id}/menu-matrix', [PermissionController::class, 'roleMenuMatrix'])->name('permissions.roleMenuMatrix');; // 부서별 권한 메트릭스
Route::get('users/{user_id}/menu-matrix', [PermissionController::class, 'userMenuMatrix'])->name('permissions.userMenuMatrix');; // 부서별 권한 메트릭스
Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('permissions.deptMenuMatrix'); // 부서별 권한 메트릭스
Route::get('roles/{role_id}/menu-matrix', [PermissionController::class, 'roleMenuMatrix'])->name('permissions.roleMenuMatrix'); // 부서별 권한 메트릭스
Route::get('users/{user_id}/menu-matrix', [PermissionController::class, 'userMenuMatrix'])->name('permissions.userMenuMatrix'); // 부서별 권한 메트릭스
});
@@ -297,17 +303,18 @@
});
// Products (모델/부품/서브어셈블리)
Route::prefix('products')->group(function () {
Route::prefix('products')->group(function (){
// (선택) 드롭다운/모달용 간편 검색 & 활성 토글
Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search');
Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle');
Route::get ('', [ProductController::class, 'index'])->name('v1.products.index'); // 목록/검색(q, category_id, product_type, active, page/size)
Route::post ('', [ProductController::class, 'store'])->name('v1.products.store'); // 생성
Route::get ('/{id}', [ProductController::class, 'show'])->name('v1.products.show'); // 단건
Route::patch ('/{id}', [ProductController::class, 'update'])->name('v1.products.update'); // 수정
Route::delete('/{id}', [ProductController::class, 'destroy'])->name('v1.products.destroy'); // 삭제(soft)
// (선택) 드롭다운/모달용 간편 검색 & 활성 토글
Route::get ('/search', [ProductController::class, 'search'])->name('v1.products.search');
Route::post ('/{id}/toggle', [ProductController::class, 'toggle'])->name('v1.products.toggle');
// BOM 카테고리
Route::get('bom/categories', [ProductBomItemController::class, 'suggestCategories'])->name('v1.products.bom.categories.suggest'); // 전역(테넌트) 추천
Route::get('{id}/bom/categories', [ProductBomItemController::class, 'listCategories'])->name('v1.products.bom.categories'); // 해당 제품에서 사용 중
@@ -330,5 +337,25 @@
Route::get('/tree', [ProductBomItemController::class, 'tree']);
});
// 설계 전용 (Design) - 운영과 분리된 네임스페이스/경로
Route::prefix('design')->group(function () {
Route::get ('/models', [DesignModelController::class, 'index'])->name('v1.design.models.index');
Route::post ('/models', [DesignModelController::class, 'store'])->name('v1.design.models.store');
Route::get ('/models/{id}', [DesignModelController::class, 'show'])->name('v1.design.models.show');
Route::put ('/models/{id}', [DesignModelController::class, 'update'])->name('v1.design.models.update');
Route::delete('/models/{id}', [DesignModelController::class, 'destroy'])->name('v1.design.models.destroy');
Route::get ('/models/{modelId}/versions', [DesignModelVersionController::class, 'index'])->name('v1.design.models.versions.index');
Route::post ('/models/{modelId}/versions', [DesignModelVersionController::class, 'createDraft'])->name('v1.design.models.versions.store');
Route::post ('/versions/{versionId}/release', [DesignModelVersionController::class, 'release'])->name('v1.design.versions.release');
Route::get ('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'listByVersion'])->name('v1.design.bom.templates.index');
Route::post ('/versions/{versionId}/bom-templates', [DesignBomTemplateController::class, 'upsertTemplate'])->name('v1.design.bom.templates.store');
Route::get ('/bom-templates/{templateId}', [DesignBomTemplateController::class, 'show'])->name('v1.design.bom.templates.show');
Route::put ('/bom-templates/{templateId}/items', [DesignBomTemplateController::class, 'replaceItems'])->name('v1.design.bom.templates.items.replace');
});
});
});