feat: 근무/출퇴근 설정 및 현장 관리 API 구현

- 근무 설정 API (GET/PUT /settings/work)
  - 근무유형, 소정근로시간, 연장근로시간, 근무요일, 출퇴근시간, 휴게시간
- 출퇴근 설정 API (GET/PUT /settings/attendance)
  - GPS 출퇴근, 허용 반경, 본사 위치 설정
- 현장 관리 API (CRUD /sites)
  - 현장 등록/수정/삭제, 활성화된 현장 목록(셀렉트박스용)
  - GPS 좌표 기반 위치 관리

마이그레이션: work_settings, attendance_settings, sites 테이블
모델: WorkSetting, AttendanceSetting, Site (BelongsToTenant, SoftDeletes)
서비스: WorkSettingService, SiteService
Swagger 문서 및 i18n 메시지 키 추가
This commit is contained in:
2025-12-17 20:46:37 +09:00
parent e81e5d7084
commit ca5618be98
19 changed files with 1523 additions and 6 deletions

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\Site\StoreSiteRequest;
use App\Http\Requests\V1\Site\UpdateSiteRequest;
use App\Http\Responses\ApiResponse;
use App\Services\SiteService;
use Illuminate\Http\Request;
class SiteController extends Controller
{
public function __construct(
private readonly SiteService $service
) {}
/**
* 현장 목록
*/
public function index(Request $request)
{
$params = $request->only([
'search',
'is_active',
'sort_by',
'sort_dir',
'per_page',
'page',
]);
$sites = $this->service->index($params);
return ApiResponse::handle(__('message.fetched'), $sites);
}
/**
* 현장 등록
*/
public function store(StoreSiteRequest $request)
{
$site = $this->service->store($request->validated());
return ApiResponse::handle(__('message.created'), $site, 201);
}
/**
* 현장 상세
*/
public function show(int $id)
{
$site = $this->service->show($id);
return ApiResponse::handle(__('message.fetched'), $site);
}
/**
* 현장 수정
*/
public function update(int $id, UpdateSiteRequest $request)
{
$site = $this->service->update($id, $request->validated());
return ApiResponse::handle(__('message.updated'), $site);
}
/**
* 현장 삭제
*/
public function destroy(int $id)
{
$this->service->destroy($id);
return ApiResponse::handle(__('message.deleted'));
}
/**
* 활성화된 현장 목록 (셀렉트박스용)
*/
public function active()
{
$sites = $this->service->getActiveSites();
return ApiResponse::handle(__('message.fetched'), $sites);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\V1\WorkSetting\UpdateAttendanceSettingRequest;
use App\Http\Requests\V1\WorkSetting\UpdateWorkSettingRequest;
use App\Http\Responses\ApiResponse;
use App\Services\WorkSettingService;
class WorkSettingController extends Controller
{
public function __construct(
private readonly WorkSettingService $service
) {}
/**
* 근무 설정 조회
*/
public function showWorkSetting()
{
$setting = $this->service->getWorkSetting();
return ApiResponse::handle(__('message.fetched'), $setting);
}
/**
* 근무 설정 수정
*/
public function updateWorkSetting(UpdateWorkSettingRequest $request)
{
$setting = $this->service->updateWorkSetting($request->validated());
return ApiResponse::handle(__('message.updated'), $setting);
}
/**
* 출퇴근 설정 조회
*/
public function showAttendanceSetting()
{
$setting = $this->service->getAttendanceSetting();
return ApiResponse::handle(__('message.fetched'), $setting);
}
/**
* 출퇴근 설정 수정
*/
public function updateAttendanceSetting(UpdateAttendanceSettingRequest $request)
{
$setting = $this->service->updateAttendanceSetting($request->validated());
return ApiResponse::handle(__('message.updated'), $setting);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\V1\Site;
use Illuminate\Foundation\Http\FormRequest;
class StoreSiteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'address' => ['nullable', 'string', 'max:255'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
'is_active' => ['sometimes', 'boolean'],
];
}
public function messages(): array
{
return [
'name.required' => __('error.site.name_required'),
'name.max' => __('error.site.name_too_long'),
'latitude.between' => __('error.site.invalid_latitude'),
'longitude.between' => __('error.site.invalid_longitude'),
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\V1\Site;
use Illuminate\Foundation\Http\FormRequest;
class UpdateSiteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['sometimes', 'string', 'max:100'],
'address' => ['nullable', 'string', 'max:255'],
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
'is_active' => ['sometimes', 'boolean'],
];
}
public function messages(): array
{
return [
'name.max' => __('error.site.name_too_long'),
'latitude.between' => __('error.site.invalid_latitude'),
'longitude.between' => __('error.site.invalid_longitude'),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\V1\WorkSetting;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAttendanceSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'use_gps' => ['sometimes', 'boolean'],
'allowed_radius' => ['sometimes', 'integer', 'min:10', 'max:10000'],
'hq_address' => ['nullable', 'string', 'max:255'],
'hq_latitude' => ['nullable', 'numeric', 'between:-90,90'],
'hq_longitude' => ['nullable', 'numeric', 'between:-180,180'],
];
}
public function messages(): array
{
return [
'allowed_radius.min' => __('error.attendance_setting.radius_too_small'),
'allowed_radius.max' => __('error.attendance_setting.radius_too_large'),
'hq_latitude.between' => __('error.attendance_setting.invalid_latitude'),
'hq_longitude.between' => __('error.attendance_setting.invalid_longitude'),
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\V1\WorkSetting;
use App\Models\Tenants\WorkSetting;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateWorkSettingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'work_type' => ['sometimes', 'string', Rule::in(WorkSetting::WORK_TYPES)],
'standard_hours' => ['sometimes', 'integer', 'min:1', 'max:168'],
'overtime_hours' => ['sometimes', 'integer', 'min:0', 'max:52'],
'overtime_limit' => ['sometimes', 'integer', 'min:0', 'max:100'],
'work_days' => ['sometimes', 'array'],
'work_days.*' => ['string', Rule::in(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])],
'start_time' => ['sometimes', 'date_format:H:i:s'],
'end_time' => ['sometimes', 'date_format:H:i:s', 'after:start_time'],
'break_minutes' => ['sometimes', 'integer', 'min:0', 'max:180'],
'break_start' => ['nullable', 'date_format:H:i:s'],
'break_end' => ['nullable', 'date_format:H:i:s', 'after:break_start'],
];
}
public function messages(): array
{
return [
'work_type.in' => __('error.work_setting.invalid_work_type'),
'end_time.after' => __('error.work_setting.end_time_after_start'),
'break_end.after' => __('error.work_setting.break_end_after_start'),
];
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* 출퇴근 설정 모델
*
* @property int $id
* @property int $tenant_id
* @property bool $use_gps
* @property int $allowed_radius
* @property string|null $hq_address
* @property float|null $hq_latitude
* @property float|null $hq_longitude
*/
class AttendanceSetting extends Model
{
use BelongsToTenant;
protected $table = 'attendance_settings';
protected $fillable = [
'tenant_id',
'use_gps',
'allowed_radius',
'hq_address',
'hq_latitude',
'hq_longitude',
];
protected $casts = [
'use_gps' => 'boolean',
'allowed_radius' => 'integer',
'hq_latitude' => 'decimal:8',
'hq_longitude' => 'decimal:8',
];
protected $attributes = [
'use_gps' => false,
'allowed_radius' => 100,
];
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* GPS 설정 완료 여부
*/
public function isGpsConfigured(): bool
{
return $this->use_gps
&& $this->hq_latitude !== null
&& $this->hq_longitude !== null;
}
/**
* 좌표가 허용 범위 내인지 확인
*/
public function isWithinRadius(float $latitude, float $longitude): bool
{
if (! $this->isGpsConfigured()) {
return true; // GPS 미설정 시 항상 허용
}
$distance = $this->calculateDistance(
$this->hq_latitude,
$this->hq_longitude,
$latitude,
$longitude
);
return $distance <= $this->allowed_radius;
}
/**
* 두 좌표 간 거리 계산 (미터)
* Haversine 공식 사용
*/
private function calculateDistance(float $lat1, float $lon1, float $lat2, float $lon2): float
{
$earthRadius = 6371000; // 지구 반경 (미터)
$lat1Rad = deg2rad($lat1);
$lat2Rad = deg2rad($lat2);
$deltaLat = deg2rad($lat2 - $lat1);
$deltaLon = deg2rad($lon2 - $lon1);
$a = sin($deltaLat / 2) * sin($deltaLat / 2) +
cos($lat1Rad) * cos($lat2Rad) *
sin($deltaLon / 2) * sin($deltaLon / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadius * $c;
}
}

125
app/Models/Tenants/Site.php Normal file
View File

@@ -0,0 +1,125 @@
<?php
namespace App\Models\Tenants;
use App\Models\Members\User;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* 현장 모델
*
* @property int $id
* @property int $tenant_id
* @property string $name
* @property string|null $address
* @property float|null $latitude
* @property float|null $longitude
* @property bool $is_active
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $deleted_by
*/
class Site extends Model
{
use BelongsToTenant, ModelTrait, SoftDeletes;
protected $table = 'sites';
protected $fillable = [
'tenant_id',
'name',
'address',
'latitude',
'longitude',
'is_active',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'latitude' => 'decimal:8',
'longitude' => 'decimal:8',
'is_active' => 'boolean',
];
protected $attributes = [
'is_active' => true,
];
// =========================================================================
// 관계 정의
// =========================================================================
/**
* 생성자
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* 수정자
*/
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* GPS 좌표 설정 여부
*/
public function hasCoordinates(): bool
{
return $this->latitude !== null && $this->longitude !== null;
}
/**
* 좌표가 허용 범위 내인지 확인
*/
public function isWithinRadius(float $latitude, float $longitude, int $radiusMeters = 100): bool
{
if (! $this->hasCoordinates()) {
return true; // 좌표 미설정 시 항상 허용
}
$distance = $this->calculateDistance(
$this->latitude,
$this->longitude,
$latitude,
$longitude
);
return $distance <= $radiusMeters;
}
/**
* 두 좌표 간 거리 계산 (미터)
*/
private function calculateDistance(float $lat1, float $lon1, float $lat2, float $lon2): float
{
$earthRadius = 6371000;
$lat1Rad = deg2rad($lat1);
$lat2Rad = deg2rad($lat2);
$deltaLat = deg2rad($lat2 - $lat1);
$deltaLon = deg2rad($lon2 - $lon1);
$a = sin($deltaLat / 2) * sin($deltaLat / 2) +
cos($lat1Rad) * cos($lat2Rad) *
sin($deltaLon / 2) * sin($deltaLon / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earthRadius * $c;
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
/**
* 근무 설정 모델
*
* @property int $id
* @property int $tenant_id
* @property string $work_type
* @property int $standard_hours
* @property int $overtime_hours
* @property int $overtime_limit
* @property array|null $work_days
* @property string $start_time
* @property string $end_time
* @property int $break_minutes
* @property string|null $break_start
* @property string|null $break_end
*/
class WorkSetting extends Model
{
use BelongsToTenant;
protected $table = 'work_settings';
protected $fillable = [
'tenant_id',
'work_type',
'standard_hours',
'overtime_hours',
'overtime_limit',
'work_days',
'start_time',
'end_time',
'break_minutes',
'break_start',
'break_end',
];
protected $casts = [
'work_days' => 'array',
'standard_hours' => 'integer',
'overtime_hours' => 'integer',
'overtime_limit' => 'integer',
'break_minutes' => 'integer',
];
protected $attributes = [
'work_type' => 'fixed',
'standard_hours' => 40,
'overtime_hours' => 12,
'overtime_limit' => 52,
'start_time' => '09:00:00',
'end_time' => '18:00:00',
'break_minutes' => 60,
'break_start' => '12:00:00',
'break_end' => '13:00:00',
];
// =========================================================================
// 상수 정의
// =========================================================================
public const TYPE_FIXED = 'fixed'; // 고정 근무
public const TYPE_FLEXIBLE = 'flexible'; // 유연 근무
public const TYPE_CUSTOM = 'custom'; // 커스텀 근무
public const WORK_TYPES = [
self::TYPE_FIXED,
self::TYPE_FLEXIBLE,
self::TYPE_CUSTOM,
];
public const DEFAULT_WORK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri'];
// =========================================================================
// 헬퍼 메서드
// =========================================================================
/**
* 근무 유형 라벨
*/
public function getWorkTypeLabelAttribute(): string
{
return match ($this->work_type) {
self::TYPE_FIXED => '고정근무',
self::TYPE_FLEXIBLE => '유연근무',
self::TYPE_CUSTOM => '커스텀',
default => $this->work_type,
};
}
/**
* 일일 근무시간 계산 (분)
*/
public function getDailyWorkMinutesAttribute(): int
{
$start = strtotime($this->start_time);
$end = strtotime($this->end_time);
$totalMinutes = ($end - $start) / 60;
return (int) ($totalMinutes - $this->break_minutes);
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Services;
use App\Models\Tenants\Site;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class SiteService extends Service
{
/**
* 현장 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Site::query()
->where('tenant_id', $tenantId);
// 검색 필터
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('address', 'like', "%{$search}%");
});
}
// 활성화 상태 필터
if (isset($params['is_active'])) {
$query->where('is_active', $params['is_active']);
}
// 정렬
$sortBy = $params['sort_by'] ?? 'created_at';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
/**
* 현장 상세 조회
*/
public function show(int $id): Site
{
$tenantId = $this->tenantId();
return Site::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
}
/**
* 현장 등록
*/
public function store(array $data): Site
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
$site = Site::create([
'tenant_id' => $tenantId,
'name' => $data['name'],
'address' => $data['address'] ?? null,
'latitude' => $data['latitude'] ?? null,
'longitude' => $data['longitude'] ?? null,
'is_active' => $data['is_active'] ?? true,
'created_by' => $userId,
'updated_by' => $userId,
]);
return $site;
});
}
/**
* 현장 수정
*/
public function update(int $id, array $data): Site
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
$site = Site::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$site->fill([
'name' => $data['name'] ?? $site->name,
'address' => $data['address'] ?? $site->address,
'latitude' => $data['latitude'] ?? $site->latitude,
'longitude' => $data['longitude'] ?? $site->longitude,
'is_active' => $data['is_active'] ?? $site->is_active,
'updated_by' => $userId,
]);
$site->save();
return $site->fresh();
});
}
/**
* 현장 삭제
*/
public function destroy(int $id): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $tenantId, $userId) {
$site = Site::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$site->deleted_by = $userId;
$site->save();
$site->delete();
return true;
});
}
/**
* 활성화된 현장 목록 조회 (셀렉트박스용)
*/
public function getActiveSites(): array
{
$tenantId = $this->tenantId();
return Site::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'address'])
->toArray();
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services;
use App\Models\Tenants\AttendanceSetting;
use App\Models\Tenants\WorkSetting;
class WorkSettingService extends Service
{
/**
* 근무 설정 조회 (없으면 생성)
*/
public function getWorkSetting(): WorkSetting
{
$tenantId = $this->tenantId();
$setting = WorkSetting::query()
->where('tenant_id', $tenantId)
->first();
if (! $setting) {
$setting = WorkSetting::create([
'tenant_id' => $tenantId,
'work_days' => WorkSetting::DEFAULT_WORK_DAYS,
]);
}
return $setting;
}
/**
* 근무 설정 수정
*/
public function updateWorkSetting(array $data): WorkSetting
{
$tenantId = $this->tenantId();
$setting = WorkSetting::query()
->where('tenant_id', $tenantId)
->first();
if (! $setting) {
$setting = new WorkSetting(['tenant_id' => $tenantId]);
}
$setting->fill($data);
$setting->save();
return $setting->fresh();
}
/**
* 출퇴근 설정 조회 (없으면 생성)
*/
public function getAttendanceSetting(): AttendanceSetting
{
$tenantId = $this->tenantId();
$setting = AttendanceSetting::query()
->where('tenant_id', $tenantId)
->first();
if (! $setting) {
$setting = AttendanceSetting::create([
'tenant_id' => $tenantId,
]);
}
return $setting;
}
/**
* 출퇴근 설정 수정
*/
public function updateAttendanceSetting(array $data): AttendanceSetting
{
$tenantId = $this->tenantId();
$setting = AttendanceSetting::query()
->where('tenant_id', $tenantId)
->first();
if (! $setting) {
$setting = new AttendanceSetting(['tenant_id' => $tenantId]);
}
$setting->fill($data);
$setting->save();
return $setting->fresh();
}
}

270
app/Swagger/v1/SiteApi.php Normal file
View File

@@ -0,0 +1,270 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Sites", description="현장 관리")
*
* @OA\Schema(
* schema="Site",
* type="object",
* description="현장 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="현장 ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="name", type="string", example="강남 현장", description="현장명"),
* @OA\Property(property="address", type="string", example="서울시 강남구 테헤란로 123", nullable=true, description="현장 주소"),
* @OA\Property(property="latitude", type="number", format="float", example=37.5012, nullable=true, description="위도"),
* @OA\Property(property="longitude", type="number", format="float", example=127.0396, nullable=true, description="경도"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성화 여부"),
* @OA\Property(property="created_by", type="integer", example=1, nullable=true, description="생성자 ID"),
* @OA\Property(property="updated_by", type="integer", example=1, nullable=true, description="수정자 ID"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="SiteCreateRequest",
* type="object",
* required={"name"},
* description="현장 등록 요청",
*
* @OA\Property(property="name", type="string", example="강남 현장", maxLength=100, description="현장명"),
* @OA\Property(property="address", type="string", example="서울시 강남구 테헤란로 123", maxLength=255, description="현장 주소"),
* @OA\Property(property="latitude", type="number", format="float", example=37.5012, minimum=-90, maximum=90, description="위도"),
* @OA\Property(property="longitude", type="number", format="float", example=127.0396, minimum=-180, maximum=180, description="경도"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성화 여부")
* )
*
* @OA\Schema(
* schema="SiteUpdateRequest",
* type="object",
* description="현장 수정 요청",
*
* @OA\Property(property="name", type="string", example="강남 현장", maxLength=100, description="현장명"),
* @OA\Property(property="address", type="string", example="서울시 강남구 테헤란로 123", maxLength=255, description="현장 주소"),
* @OA\Property(property="latitude", type="number", format="float", example=37.5012, minimum=-90, maximum=90, description="위도"),
* @OA\Property(property="longitude", type="number", format="float", example=127.0396, minimum=-180, maximum=180, description="경도"),
* @OA\Property(property="is_active", type="boolean", example=true, description="활성화 여부")
* )
*
* @OA\Schema(
* schema="SiteListItem",
* type="object",
* description="현장 목록 아이템 (셀렉트박스용)",
*
* @OA\Property(property="id", type="integer", example=1, description="현장 ID"),
* @OA\Property(property="name", type="string", example="강남 현장", description="현장명"),
* @OA\Property(property="address", type="string", example="서울시 강남구", nullable=true, description="현장 주소")
* )
*/
class SiteApi
{
/**
* @OA\Get(
* path="/api/v1/sites",
* tags={"Sites"},
* summary="현장 목록 조회",
* description="현장 목록을 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="search", in="query", description="검색어 (현장명, 주소)", @OA\Schema(type="string")),
* @OA\Parameter(name="is_active", in="query", description="활성화 상태 필터", @OA\Schema(type="boolean")),
* @OA\Parameter(name="sort_by", in="query", description="정렬 기준", @OA\Schema(type="string", enum={"name","created_at"}, default="created_at")),
* @OA\Parameter(name="sort_dir", in="query", description="정렬 방향", @OA\Schema(type="string", enum={"asc","desc"}, default="desc")),
* @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",
* type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Site")),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=10)
* )
* )
* }
* )
* ),
*
* @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/sites",
* tags={"Sites"},
* summary="현장 등록",
* description="새로운 현장을 등록합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/SiteCreateRequest")
* ),
*
* @OA\Response(
* response=201,
* description="등록 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/Site")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Get(
* path="/api/v1/sites/active",
* tags={"Sites"},
* summary="활성화된 현장 목록 (셀렉트박스용)",
* description="활성화된 현장 목록을 간단한 형태로 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @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/SiteListItem"))
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function active() {}
/**
* @OA\Get(
* path="/api/v1/sites/{id}",
* tags={"Sites"},
* summary="현장 상세 조회",
* description="현장 상세 정보를 조회합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="현장 ID", @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/Site")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="현장 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Put(
* path="/api/v1/sites/{id}",
* tags={"Sites"},
* summary="현장 수정",
* description="현장 정보를 수정합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="현장 ID", @OA\Schema(type="integer")),
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/SiteUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/Site")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="현장 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/sites/{id}",
* tags={"Sites"},
* summary="현장 삭제",
* description="현장을 삭제합니다. (Soft Delete)",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Parameter(name="id", in="path", required=true, description="현장 ID", @OA\Schema(type="integer")),
*
* @OA\Response(
* response=200,
* description="삭제 성공",
*
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="현장 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
}

View File

@@ -0,0 +1,209 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="WorkSettings", description="근무/출퇴근 설정")
*
* @OA\Schema(
* schema="WorkSetting",
* type="object",
* description="근무 설정 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="work_type", type="string", enum={"fixed","flexible","custom"}, example="fixed", description="근무유형"),
* @OA\Property(property="standard_hours", type="integer", example=40, description="주당 소정근로시간"),
* @OA\Property(property="overtime_hours", type="integer", example=12, description="주당 연장근로시간"),
* @OA\Property(property="overtime_limit", type="integer", example=52, description="연장근로한도"),
* @OA\Property(property="work_days", type="array", @OA\Items(type="string"), example={"mon","tue","wed","thu","fri"}, description="근무요일"),
* @OA\Property(property="start_time", type="string", format="time", example="09:00:00", description="출근시간"),
* @OA\Property(property="end_time", type="string", format="time", example="18:00:00", description="퇴근시간"),
* @OA\Property(property="break_minutes", type="integer", example=60, description="휴게시간(분)"),
* @OA\Property(property="break_start", type="string", format="time", example="12:00:00", nullable=true, description="휴게시작"),
* @OA\Property(property="break_end", type="string", format="time", example="13:00:00", nullable=true, description="휴게종료"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="WorkSettingUpdateRequest",
* type="object",
* description="근무 설정 수정 요청",
*
* @OA\Property(property="work_type", type="string", enum={"fixed","flexible","custom"}, example="fixed", description="근무유형"),
* @OA\Property(property="standard_hours", type="integer", example=40, minimum=1, maximum=168, description="주당 소정근로시간"),
* @OA\Property(property="overtime_hours", type="integer", example=12, minimum=0, maximum=52, description="주당 연장근로시간"),
* @OA\Property(property="overtime_limit", type="integer", example=52, minimum=0, maximum=100, description="연장근로한도"),
* @OA\Property(property="work_days", type="array", @OA\Items(type="string", enum={"mon","tue","wed","thu","fri","sat","sun"}), description="근무요일"),
* @OA\Property(property="start_time", type="string", format="time", example="09:00:00", description="출근시간"),
* @OA\Property(property="end_time", type="string", format="time", example="18:00:00", description="퇴근시간"),
* @OA\Property(property="break_minutes", type="integer", example=60, minimum=0, maximum=180, description="휴게시간(분)"),
* @OA\Property(property="break_start", type="string", format="time", example="12:00:00", description="휴게시작"),
* @OA\Property(property="break_end", type="string", format="time", example="13:00:00", description="휴게종료")
* )
*
* @OA\Schema(
* schema="AttendanceSetting",
* type="object",
* description="출퇴근 설정 정보",
*
* @OA\Property(property="id", type="integer", example=1, description="ID"),
* @OA\Property(property="tenant_id", type="integer", example=1, description="테넌트 ID"),
* @OA\Property(property="use_gps", type="boolean", example=true, description="GPS 출퇴근 사용 여부"),
* @OA\Property(property="allowed_radius", type="integer", example=100, description="허용 반경(m)"),
* @OA\Property(property="hq_address", type="string", example="서울시 강남구 테헤란로 123", nullable=true, description="본사 주소"),
* @OA\Property(property="hq_latitude", type="number", format="float", example=37.5012, nullable=true, description="본사 위도"),
* @OA\Property(property="hq_longitude", type="number", format="float", example=127.0396, nullable=true, description="본사 경도"),
* @OA\Property(property="created_at", type="string", format="date-time"),
* @OA\Property(property="updated_at", type="string", format="date-time")
* )
*
* @OA\Schema(
* schema="AttendanceSettingUpdateRequest",
* type="object",
* description="출퇴근 설정 수정 요청",
*
* @OA\Property(property="use_gps", type="boolean", example=true, description="GPS 출퇴근 사용 여부"),
* @OA\Property(property="allowed_radius", type="integer", example=100, minimum=10, maximum=10000, description="허용 반경(m)"),
* @OA\Property(property="hq_address", type="string", example="서울시 강남구 테헤란로 123", description="본사 주소"),
* @OA\Property(property="hq_latitude", type="number", format="float", example=37.5012, minimum=-90, maximum=90, description="본사 위도"),
* @OA\Property(property="hq_longitude", type="number", format="float", example=127.0396, minimum=-180, maximum=180, description="본사 경도")
* )
*/
class WorkSettingApi
{
/**
* @OA\Get(
* path="/api/v1/settings/work",
* tags={"WorkSettings"},
* summary="근무 설정 조회",
* description="테넌트의 근무 설정을 조회합니다. 설정이 없으면 기본값으로 자동 생성됩니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/WorkSetting")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function showWorkSetting() {}
/**
* @OA\Put(
* path="/api/v1/settings/work",
* tags={"WorkSettings"},
* summary="근무 설정 수정",
* description="테넌트의 근무 설정을 수정합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/WorkSettingUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/WorkSetting")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function updateWorkSetting() {}
/**
* @OA\Get(
* path="/api/v1/settings/attendance",
* tags={"WorkSettings"},
* summary="출퇴근 설정 조회",
* description="테넌트의 출퇴근 설정을 조회합니다. 설정이 없으면 기본값으로 자동 생성됩니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\Response(
* response=200,
* description="조회 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/AttendanceSetting")
* )
* }
* )
* ),
*
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function showAttendanceSetting() {}
/**
* @OA\Put(
* path="/api/v1/settings/attendance",
* tags={"WorkSettings"},
* summary="출퇴근 설정 수정",
* description="테넌트의 출퇴근 설정을 수정합니다.",
* security={{"ApiKeyAuth":{}},{"BearerAuth":{}}},
*
* @OA\RequestBody(
* required=true,
*
* @OA\JsonContent(ref="#/components/schemas/AttendanceSettingUpdateRequest")
* ),
*
* @OA\Response(
* response=200,
* description="수정 성공",
*
* @OA\JsonContent(
* allOf={
*
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
*
* @OA\Property(property="data", ref="#/components/schemas/AttendanceSetting")
* )
* }
* )
* ),
*
* @OA\Response(response=400, description="잘못된 요청", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function updateAttendanceSetting() {}
}

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('work_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->unique()->comment('테넌트 ID');
$table->string('work_type', 20)->default('fixed')->comment('근무유형: fixed/flexible/custom');
$table->integer('standard_hours')->default(40)->comment('주당 소정근로시간');
$table->integer('overtime_hours')->default(12)->comment('주당 연장근로시간');
$table->integer('overtime_limit')->default(52)->comment('연장근로한도');
$table->json('work_days')->nullable()->comment('근무요일 ["mon","tue","wed","thu","fri"]');
$table->time('start_time')->default('09:00:00')->comment('출근시간');
$table->time('end_time')->default('18:00:00')->comment('퇴근시간');
$table->integer('break_minutes')->default(60)->comment('휴게시간(분)');
$table->time('break_start')->nullable()->default('12:00:00')->comment('휴게시작');
$table->time('break_end')->nullable()->default('13:00:00')->comment('휴게종료');
$table->timestamps();
$table->index('tenant_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('work_settings');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attendance_settings', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->unique()->comment('테넌트 ID');
$table->boolean('use_gps')->default(false)->comment('GPS 출퇴근 사용 여부');
$table->integer('allowed_radius')->default(100)->comment('허용 반경(m)');
$table->string('hq_address', 255)->nullable()->comment('본사 주소');
$table->decimal('hq_latitude', 10, 8)->nullable()->comment('본사 위도');
$table->decimal('hq_longitude', 11, 8)->nullable()->comment('본사 경도');
$table->timestamps();
$table->index('tenant_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attendance_settings');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sites', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->comment('테넌트 ID');
$table->string('name', 100)->comment('현장명');
$table->string('address', 255)->nullable()->comment('현장 주소');
$table->decimal('latitude', 10, 8)->nullable()->comment('위도');
$table->decimal('longitude', 11, 8)->nullable()->comment('경도');
$table->boolean('is_active')->default(true)->comment('활성화 여부');
$table->foreignId('created_by')->nullable()->comment('생성자');
$table->foreignId('updated_by')->nullable()->comment('수정자');
$table->foreignId('deleted_by')->nullable()->comment('삭제자');
$table->softDeletes();
$table->timestamps();
$table->index('tenant_id');
$table->index('is_active');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sites');
}
};

View File

@@ -110,6 +110,8 @@
'section_not_found' => '섹션을 찾을 수 없습니다.',
'field_not_found' => '필드를 찾을 수 없습니다.',
'field_key_reserved' => '":field_key"은(는) 시스템 예약어로 사용할 수 없습니다.',
'field_key_system_readonly' => '시스템 필드의 field_key는 변경할 수 없습니다.',
'field_key_system_cannot_delete' => '시스템 필드는 삭제할 수 없습니다.',
'bom_not_found' => 'BOM 항목을 찾을 수 없습니다.',
'item_type_required' => '품목 유형(item_type)은 필수입니다.',
'item_type_or_group_required' => '품목 유형(item_type) 또는 그룹 ID(group_id)는 필수입니다.',
@@ -163,4 +165,28 @@
'overlapping' => '해당 기간에 이미 신청된 휴가가 있습니다.',
'balance_not_found' => '휴가 잔여일수 정보를 찾을 수 없습니다.',
],
// 근무 설정 관련
'work_setting' => [
'invalid_work_type' => '유효하지 않은 근무 유형입니다.',
'invalid_work_days' => '유효하지 않은 근무요일입니다.',
'invalid_time_range' => '출근시간은 퇴근시간보다 이전이어야 합니다.',
'break_time_invalid' => '휴게시간 설정이 올바르지 않습니다.',
],
// 출퇴근 설정 관련
'attendance_setting' => [
'invalid_radius' => '허용 반경이 유효하지 않습니다.',
'invalid_coordinates' => '좌표가 유효하지 않습니다.',
],
// 현장 관리 관련
'site' => [
'not_found' => '현장 정보를 찾을 수 없습니다.',
'name_required' => '현장명은 필수입니다.',
'name_too_long' => '현장명은 100자를 초과할 수 없습니다.',
'invalid_latitude' => '위도는 -90 ~ 90 사이여야 합니다.',
'invalid_longitude' => '경도는 -180 ~ 180 사이여야 합니다.',
'has_dependencies' => '연관된 데이터가 있어 삭제할 수 없습니다.',
],
];

View File

@@ -209,4 +209,25 @@
'balance_fetched' => '잔여 휴가를 조회했습니다.',
'balance_updated' => '휴가 일수가 설정되었습니다.',
],
// 근무 설정 관리
'work_setting' => [
'fetched' => '근무 설정을 조회했습니다.',
'updated' => '근무 설정이 수정되었습니다.',
],
// 출퇴근 설정 관리
'attendance_setting' => [
'fetched' => '출퇴근 설정을 조회했습니다.',
'updated' => '출퇴근 설정이 수정되었습니다.',
],
// 현장 관리
'site' => [
'fetched' => '현장을 조회했습니다.',
'created' => '현장이 등록되었습니다.',
'updated' => '현장이 수정되었습니다.',
'deleted' => '현장이 삭제되었습니다.',
'active_fetched' => '활성화된 현장 목록을 조회했습니다.',
],
];

View File

@@ -23,7 +23,6 @@
use App\Http\Controllers\Api\V1\EstimateController;
use App\Http\Controllers\Api\V1\FileStorageController;
use App\Http\Controllers\Api\V1\FolderController;
use App\Http\Controllers\Api\V1\LeaveController;
use App\Http\Controllers\Api\V1\ItemMaster\CustomTabController;
use App\Http\Controllers\Api\V1\ItemMaster\EntityRelationshipController;
use App\Http\Controllers\Api\V1\ItemMaster\ItemBomItemController;
@@ -36,29 +35,32 @@
use App\Http\Controllers\Api\V1\ItemsBomController;
use App\Http\Controllers\Api\V1\ItemsController;
use App\Http\Controllers\Api\V1\ItemsFileController;
// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨
use App\Http\Controllers\Api\V1\LeaveController;
use App\Http\Controllers\Api\V1\MenuController;
use App\Http\Controllers\Api\V1\ModelSetController;
// use App\Http\Controllers\Api\V1\MaterialController; // REMOVED: materials 테이블 삭제됨
use App\Http\Controllers\Api\V1\PermissionController;
use App\Http\Controllers\Api\V1\PostController;
use App\Http\Controllers\Api\V1\PricingController;
// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨
// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨
use App\Http\Controllers\Api\V1\QuoteController;
use App\Http\Controllers\Api\V1\RefreshController;
// use App\Http\Controllers\Api\V1\ProductBomItemController; // REMOVED: products 테이블 삭제됨
// use App\Http\Controllers\Api\V1\ProductController; // REMOVED: products 테이블 삭제됨
use App\Http\Controllers\Api\V1\RegisterController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\RolePermissionController;
use App\Http\Controllers\Api\V1\SiteController;
use App\Http\Controllers\Api\V1\TenantController;
// 설계 전용 (디자인 네임스페이스)
use App\Http\Controllers\Api\V1\TenantFieldSettingController;
// 설계 전용 (디자인 네임스페이스)
use App\Http\Controllers\Api\V1\TenantOptionGroupController;
use App\Http\Controllers\Api\V1\TenantOptionValueController;
use App\Http\Controllers\Api\V1\TenantStatFieldController;
use App\Http\Controllers\Api\V1\TenantUserProfileController;
// 모델셋 관리 (견적 시스템)
use App\Http\Controllers\Api\V1\UserController;
// 모델셋 관리 (견적 시스템)
use App\Http\Controllers\Api\V1\UserRoleController;
use App\Http\Controllers\Api\V1\WorkSettingController;
use Illuminate\Support\Facades\Route;
// V1 초기 개발
@@ -257,6 +259,16 @@
Route::post('/{id}/cancel', [LeaveController::class, 'cancel'])->name('v1.leaves.cancel');
});
// Site API (현장 관리)
Route::prefix('sites')->group(function () {
Route::get('', [SiteController::class, 'index'])->name('v1.sites.index');
Route::post('', [SiteController::class, 'store'])->name('v1.sites.store');
Route::get('/active', [SiteController::class, 'active'])->name('v1.sites.active');
Route::get('/{id}', [SiteController::class, 'show'])->whereNumber('id')->name('v1.sites.show');
Route::put('/{id}', [SiteController::class, 'update'])->whereNumber('id')->name('v1.sites.update');
Route::delete('/{id}', [SiteController::class, 'destroy'])->whereNumber('id')->name('v1.sites.destroy');
});
// Permission API
Route::prefix('permissions')->group(function () {
Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스
@@ -267,6 +279,14 @@
// Settings & Configuration (설정 및 환경설정 통합 관리)
Route::prefix('settings')->group(function () {
// 근무 설정
Route::get('/work', [WorkSettingController::class, 'showWorkSetting'])->name('v1.settings.work.show');
Route::put('/work', [WorkSettingController::class, 'updateWorkSetting'])->name('v1.settings.work.update');
// 출퇴근 설정
Route::get('/attendance', [WorkSettingController::class, 'showAttendanceSetting'])->name('v1.settings.attendance.show');
Route::put('/attendance', [WorkSettingController::class, 'updateAttendanceSetting'])->name('v1.settings.attendance.update');
// 테넌트 필드 설정 (기존 fields에서 이동)
Route::get('/fields', [TenantFieldSettingController::class, 'index'])->name('v1.settings.fields.index'); // 필드 설정 목록(전역+테넌트 병합 효과값)
Route::put('/fields/bulk', [TenantFieldSettingController::class, 'bulkUpsert'])->name('v1.settings.fields.bulk'); // 필드 설정 대량 저장(트랜잭션 처리)