From e065b5716137a0ef43f761fd59423b0dfb29bab8 Mon Sep 17 00:00:00 2001 From: solutionforestteam Date: Tue, 18 Aug 2026 23:23:23 +0800 Subject: [PATCH] feat(demo): mirror PHP showcase modules --- README.md | 28 ++- app/Http/Middleware/HandleAppearance.php | 2 +- app/Inlay/Resources/Blog/AuthorCreate.php | 12 ++ app/Inlay/Resources/Blog/AuthorEdit.php | 12 ++ app/Inlay/Resources/Blog/AuthorList.php | 14 ++ app/Inlay/Resources/Blog/AuthorResource.php | 104 ++++++++++ app/Inlay/Resources/Blog/AuthorView.php | 12 ++ app/Inlay/Resources/Blog/CategoryCreate.php | 12 ++ app/Inlay/Resources/Blog/CategoryEdit.php | 12 ++ app/Inlay/Resources/Blog/CategoryList.php | 14 ++ app/Inlay/Resources/Blog/CategoryResource.php | 81 ++++++++ app/Inlay/Resources/Blog/CategoryView.php | 12 ++ app/Inlay/Resources/BlogResource.php | 24 +++ app/Inlay/Resources/CreateBlog.php | 2 +- app/Inlay/Resources/EditBlog.php | 2 +- app/Inlay/Resources/Hr/DepartmentCreate.php | 12 ++ app/Inlay/Resources/Hr/DepartmentEdit.php | 12 ++ app/Inlay/Resources/Hr/DepartmentList.php | 14 ++ app/Inlay/Resources/Hr/DepartmentResource.php | 83 ++++++++ app/Inlay/Resources/Hr/DepartmentView.php | 12 ++ app/Inlay/Resources/Hr/EmployeeCreate.php | 12 ++ app/Inlay/Resources/Hr/EmployeeEdit.php | 12 ++ app/Inlay/Resources/Hr/EmployeeList.php | 14 ++ app/Inlay/Resources/Hr/EmployeeResource.php | 98 ++++++++++ app/Inlay/Resources/Hr/EmployeeView.php | 12 ++ app/Inlay/Resources/Hr/ExpenseCreate.php | 12 ++ app/Inlay/Resources/Hr/ExpenseEdit.php | 12 ++ app/Inlay/Resources/Hr/ExpenseList.php | 14 ++ app/Inlay/Resources/Hr/ExpenseResource.php | 93 +++++++++ app/Inlay/Resources/Hr/ExpenseView.php | 12 ++ app/Inlay/Resources/Hr/LeaveRequestCreate.php | 12 ++ app/Inlay/Resources/Hr/LeaveRequestEdit.php | 12 ++ app/Inlay/Resources/Hr/LeaveRequestList.php | 26 +++ .../Resources/Hr/LeaveRequestResource.php | 91 +++++++++ app/Inlay/Resources/Hr/LeaveRequestView.php | 12 ++ app/Inlay/Resources/Hr/ProjectCreate.php | 12 ++ app/Inlay/Resources/Hr/ProjectEdit.php | 12 ++ app/Inlay/Resources/Hr/ProjectList.php | 14 ++ app/Inlay/Resources/Hr/ProjectResource.php | 98 ++++++++++ app/Inlay/Resources/Hr/ProjectView.php | 12 ++ app/Inlay/Resources/Hr/TaskCreate.php | 12 ++ app/Inlay/Resources/Hr/TaskEdit.php | 12 ++ app/Inlay/Resources/Hr/TaskList.php | 14 ++ app/Inlay/Resources/Hr/TaskResource.php | 84 ++++++++ app/Inlay/Resources/Hr/TaskView.php | 12 ++ app/Inlay/Resources/Hr/TimesheetCreate.php | 12 ++ app/Inlay/Resources/Hr/TimesheetEdit.php | 12 ++ app/Inlay/Resources/Hr/TimesheetList.php | 14 ++ app/Inlay/Resources/Hr/TimesheetResource.php | 85 ++++++++ app/Inlay/Resources/Hr/TimesheetView.php | 12 ++ app/Inlay/Resources/ListBlogs.php | 2 +- .../Resources/Pages/GenericCreatePage.php | 12 ++ app/Inlay/Resources/Pages/GenericEditPage.php | 12 ++ app/Inlay/Resources/Pages/GenericListPage.php | 12 ++ app/Inlay/Resources/Pages/GenericViewPage.php | 12 ++ app/Inlay/Resources/Shop/BrandCreate.php | 12 ++ app/Inlay/Resources/Shop/BrandEdit.php | 12 ++ app/Inlay/Resources/Shop/BrandList.php | 14 ++ app/Inlay/Resources/Shop/BrandResource.php | 83 ++++++++ app/Inlay/Resources/Shop/BrandView.php | 12 ++ app/Inlay/Resources/Shop/CustomerCreate.php | 12 ++ app/Inlay/Resources/Shop/CustomerEdit.php | 12 ++ app/Inlay/Resources/Shop/CustomerList.php | 14 ++ app/Inlay/Resources/Shop/CustomerResource.php | 115 +++++++++++ app/Inlay/Resources/Shop/CustomerView.php | 12 ++ app/Inlay/Resources/Shop/OrderCreate.php | 12 ++ app/Inlay/Resources/Shop/OrderEdit.php | 12 ++ app/Inlay/Resources/Shop/OrderList.php | 27 +++ app/Inlay/Resources/Shop/OrderResource.php | 134 +++++++++++++ app/Inlay/Resources/Shop/OrderView.php | 12 ++ .../Resources/Shop/ProductCategoryCreate.php | 12 ++ .../Resources/Shop/ProductCategoryEdit.php | 12 ++ .../Resources/Shop/ProductCategoryList.php | 14 ++ .../Shop/ProductCategoryResource.php | 79 ++++++++ .../Resources/Shop/ProductCategoryView.php | 12 ++ app/Inlay/Resources/Shop/ProductCreate.php | 12 ++ app/Inlay/Resources/Shop/ProductEdit.php | 12 ++ app/Inlay/Resources/Shop/ProductList.php | 14 ++ app/Inlay/Resources/Shop/ProductResource.php | 124 ++++++++++++ app/Inlay/Resources/Shop/ProductView.php | 12 ++ app/Inlay/Resources/UserResource.php | 4 + app/Inlay/Resources/ViewBlog.php | 12 ++ app/Inlay/Widgets/AdminDashboardWidgets.php | 53 ++++- app/Models/Blog/Author.php | 24 +++ app/Models/Blog/Category.php | 19 ++ app/Models/Hr/Department.php | 19 ++ app/Models/Hr/Employee.php | 29 +++ app/Models/Hr/Expense.php | 29 +++ app/Models/Hr/LeaveRequest.php | 27 +++ app/Models/Hr/Project.php | 28 +++ app/Models/Hr/Task.php | 24 +++ app/Models/Hr/Timesheet.php | 24 +++ app/Models/Shop/Brand.php | 19 ++ app/Models/Shop/Customer.php | 19 ++ app/Models/Shop/Order.php | 35 ++++ app/Models/Shop/Product.php | 28 +++ app/Models/Shop/ProductCategory.php | 19 ++ app/Providers/Inlay/AdminPanelProvider.php | 34 +++- app/Validation/ShowcaseRules.php | 148 ++++++++++++++ database/factories/Blog/AuthorFactory.php | 24 +++ database/factories/Blog/CategoryFactory.php | 26 +++ database/factories/Hr/DepartmentFactory.php | 19 ++ database/factories/Hr/EmployeeFactory.php | 29 +++ database/factories/Hr/ExpenseFactory.php | 31 +++ database/factories/Hr/LeaveRequestFactory.php | 28 +++ database/factories/Hr/ProjectFactory.php | 29 +++ database/factories/Hr/TaskFactory.php | 19 ++ database/factories/Hr/TimesheetFactory.php | 19 ++ database/factories/Shop/BrandFactory.php | 22 +++ database/factories/Shop/CustomerFactory.php | 25 +++ database/factories/Shop/OrderFactory.php | 31 +++ .../factories/Shop/ProductCategoryFactory.php | 22 +++ database/factories/Shop/ProductFactory.php | 30 +++ ...26_08_18_000000_create_showcase_tables.php | 181 ++++++++++++++++++ database/seeders/DatabaseSeeder.php | 117 +++++++++-- package.json | 1 + pnpm-lock.yaml | 27 +++ resources/js/hooks/use-appearance.tsx | 10 +- resources/js/pages/inlay/auth/login.tsx | 41 +++- resources/js/pages/inlay/dashboard.tsx | 149 ++------------ resources/js/pages/inlay/resource/form.tsx | 57 ++++++ resources/js/pages/inlay/resource/index.tsx | 92 +++++++++ resources/js/pages/inlay/resource/view.tsx | 59 ++++++ resources/views/app.blade.php | 2 +- tests/Feature/BlogResourceTest.php | 6 +- tests/Feature/DashboardTest.php | 2 + tests/Feature/ShowcaseResourcesTest.php | 121 ++++++++++++ tests/Feature/UserManagementTest.php | 10 +- 128 files changed, 3832 insertions(+), 176 deletions(-) create mode 100644 app/Inlay/Resources/Blog/AuthorCreate.php create mode 100644 app/Inlay/Resources/Blog/AuthorEdit.php create mode 100644 app/Inlay/Resources/Blog/AuthorList.php create mode 100644 app/Inlay/Resources/Blog/AuthorResource.php create mode 100644 app/Inlay/Resources/Blog/AuthorView.php create mode 100644 app/Inlay/Resources/Blog/CategoryCreate.php create mode 100644 app/Inlay/Resources/Blog/CategoryEdit.php create mode 100644 app/Inlay/Resources/Blog/CategoryList.php create mode 100644 app/Inlay/Resources/Blog/CategoryResource.php create mode 100644 app/Inlay/Resources/Blog/CategoryView.php create mode 100644 app/Inlay/Resources/Hr/DepartmentCreate.php create mode 100644 app/Inlay/Resources/Hr/DepartmentEdit.php create mode 100644 app/Inlay/Resources/Hr/DepartmentList.php create mode 100644 app/Inlay/Resources/Hr/DepartmentResource.php create mode 100644 app/Inlay/Resources/Hr/DepartmentView.php create mode 100644 app/Inlay/Resources/Hr/EmployeeCreate.php create mode 100644 app/Inlay/Resources/Hr/EmployeeEdit.php create mode 100644 app/Inlay/Resources/Hr/EmployeeList.php create mode 100644 app/Inlay/Resources/Hr/EmployeeResource.php create mode 100644 app/Inlay/Resources/Hr/EmployeeView.php create mode 100644 app/Inlay/Resources/Hr/ExpenseCreate.php create mode 100644 app/Inlay/Resources/Hr/ExpenseEdit.php create mode 100644 app/Inlay/Resources/Hr/ExpenseList.php create mode 100644 app/Inlay/Resources/Hr/ExpenseResource.php create mode 100644 app/Inlay/Resources/Hr/ExpenseView.php create mode 100644 app/Inlay/Resources/Hr/LeaveRequestCreate.php create mode 100644 app/Inlay/Resources/Hr/LeaveRequestEdit.php create mode 100644 app/Inlay/Resources/Hr/LeaveRequestList.php create mode 100644 app/Inlay/Resources/Hr/LeaveRequestResource.php create mode 100644 app/Inlay/Resources/Hr/LeaveRequestView.php create mode 100644 app/Inlay/Resources/Hr/ProjectCreate.php create mode 100644 app/Inlay/Resources/Hr/ProjectEdit.php create mode 100644 app/Inlay/Resources/Hr/ProjectList.php create mode 100644 app/Inlay/Resources/Hr/ProjectResource.php create mode 100644 app/Inlay/Resources/Hr/ProjectView.php create mode 100644 app/Inlay/Resources/Hr/TaskCreate.php create mode 100644 app/Inlay/Resources/Hr/TaskEdit.php create mode 100644 app/Inlay/Resources/Hr/TaskList.php create mode 100644 app/Inlay/Resources/Hr/TaskResource.php create mode 100644 app/Inlay/Resources/Hr/TaskView.php create mode 100644 app/Inlay/Resources/Hr/TimesheetCreate.php create mode 100644 app/Inlay/Resources/Hr/TimesheetEdit.php create mode 100644 app/Inlay/Resources/Hr/TimesheetList.php create mode 100644 app/Inlay/Resources/Hr/TimesheetResource.php create mode 100644 app/Inlay/Resources/Hr/TimesheetView.php create mode 100644 app/Inlay/Resources/Pages/GenericCreatePage.php create mode 100644 app/Inlay/Resources/Pages/GenericEditPage.php create mode 100644 app/Inlay/Resources/Pages/GenericListPage.php create mode 100644 app/Inlay/Resources/Pages/GenericViewPage.php create mode 100644 app/Inlay/Resources/Shop/BrandCreate.php create mode 100644 app/Inlay/Resources/Shop/BrandEdit.php create mode 100644 app/Inlay/Resources/Shop/BrandList.php create mode 100644 app/Inlay/Resources/Shop/BrandResource.php create mode 100644 app/Inlay/Resources/Shop/BrandView.php create mode 100644 app/Inlay/Resources/Shop/CustomerCreate.php create mode 100644 app/Inlay/Resources/Shop/CustomerEdit.php create mode 100644 app/Inlay/Resources/Shop/CustomerList.php create mode 100644 app/Inlay/Resources/Shop/CustomerResource.php create mode 100644 app/Inlay/Resources/Shop/CustomerView.php create mode 100644 app/Inlay/Resources/Shop/OrderCreate.php create mode 100644 app/Inlay/Resources/Shop/OrderEdit.php create mode 100644 app/Inlay/Resources/Shop/OrderList.php create mode 100644 app/Inlay/Resources/Shop/OrderResource.php create mode 100644 app/Inlay/Resources/Shop/OrderView.php create mode 100644 app/Inlay/Resources/Shop/ProductCategoryCreate.php create mode 100644 app/Inlay/Resources/Shop/ProductCategoryEdit.php create mode 100644 app/Inlay/Resources/Shop/ProductCategoryList.php create mode 100644 app/Inlay/Resources/Shop/ProductCategoryResource.php create mode 100644 app/Inlay/Resources/Shop/ProductCategoryView.php create mode 100644 app/Inlay/Resources/Shop/ProductCreate.php create mode 100644 app/Inlay/Resources/Shop/ProductEdit.php create mode 100644 app/Inlay/Resources/Shop/ProductList.php create mode 100644 app/Inlay/Resources/Shop/ProductResource.php create mode 100644 app/Inlay/Resources/Shop/ProductView.php create mode 100644 app/Inlay/Resources/ViewBlog.php create mode 100644 app/Models/Blog/Author.php create mode 100644 app/Models/Blog/Category.php create mode 100644 app/Models/Hr/Department.php create mode 100644 app/Models/Hr/Employee.php create mode 100644 app/Models/Hr/Expense.php create mode 100644 app/Models/Hr/LeaveRequest.php create mode 100644 app/Models/Hr/Project.php create mode 100644 app/Models/Hr/Task.php create mode 100644 app/Models/Hr/Timesheet.php create mode 100644 app/Models/Shop/Brand.php create mode 100644 app/Models/Shop/Customer.php create mode 100644 app/Models/Shop/Order.php create mode 100644 app/Models/Shop/Product.php create mode 100644 app/Models/Shop/ProductCategory.php create mode 100644 app/Validation/ShowcaseRules.php create mode 100644 database/factories/Blog/AuthorFactory.php create mode 100644 database/factories/Blog/CategoryFactory.php create mode 100644 database/factories/Hr/DepartmentFactory.php create mode 100644 database/factories/Hr/EmployeeFactory.php create mode 100644 database/factories/Hr/ExpenseFactory.php create mode 100644 database/factories/Hr/LeaveRequestFactory.php create mode 100644 database/factories/Hr/ProjectFactory.php create mode 100644 database/factories/Hr/TaskFactory.php create mode 100644 database/factories/Hr/TimesheetFactory.php create mode 100644 database/factories/Shop/BrandFactory.php create mode 100644 database/factories/Shop/CustomerFactory.php create mode 100644 database/factories/Shop/OrderFactory.php create mode 100644 database/factories/Shop/ProductCategoryFactory.php create mode 100644 database/factories/Shop/ProductFactory.php create mode 100644 database/migrations/2026_08_18_000000_create_showcase_tables.php create mode 100644 resources/js/pages/inlay/resource/form.tsx create mode 100644 resources/js/pages/inlay/resource/index.tsx create mode 100644 resources/js/pages/inlay/resource/view.tsx create mode 100644 tests/Feature/ShowcaseResourcesTest.php diff --git a/README.md b/README.md index 5c7025b..3b97a55 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,22 @@ User resource with create/edit/delete, theming, and the Media Manager. The generated application code remains editable; package code stays in `vendor/` and `node_modules/`. -This demo also registers a small Blog resource at `/admin/blogs`. Its create and -edit screens use the Forms package, its list screen uses the Tables package, and -its centralized `BlogRules` class keeps validation in application code. It is a -compact example of the workflow a real Laravel resource can grow into. +This demo also registers a PHP-authored showcase across three navigation groups: + +- **Shop** — products, product categories, brands, customers, and orders (status + tabs, money columns, + repeatable line items, filters, badges, and order detail infolists). +- **Blog** — posts, authors, and categories (content forms, status views, + searchable tables, copyable slugs, and read-only detail pages). +- **HR & projects** — departments, employees, projects, tasks, timesheets, + leave requests, and expenses (checkbox skill lists, key/value metadata, + repeatable plans and expense lines, workflow tabs, and central validation). + +Every resource uses the shared Inlay resource pages and renderer contracts. The +React files under `resources/js/pages/inlay/resource/` are intentionally generic +mount points; the schema, table, infolist, actions, authorization, validation, +navigation group, and seeded data are all defined in PHP. `ShowcaseRules` is an +application example, not a package rule set. The public demo adds two standalone examples outside the panel: @@ -42,8 +54,12 @@ The public demo adds two standalone examples outside the panel: Those package examples, together with the GitHub source link, are grouped under **Package demos** in the panel navigation and open in a new browser tab. -The landing page links to those examples, the complete panel, and the Inlay -source repository. CMS packages are intentionally excluded. +The dashboard widgets are also resolved from PHP and include stats, charts, and +recent blog/order tables. CMS packages are intentionally excluded from this +demo so the admin surface stays focused on the core Inlay experience. The +default Media Manager seed includes a text asset and two SVG dashboard assets, +so the picker and delivery routes have useful content immediately after +`migrate --seed`. ## Demo login diff --git a/app/Http/Middleware/HandleAppearance.php b/app/Http/Middleware/HandleAppearance.php index 1a01684..7829afa 100644 --- a/app/Http/Middleware/HandleAppearance.php +++ b/app/Http/Middleware/HandleAppearance.php @@ -16,7 +16,7 @@ class HandleAppearance */ public function handle(Request $request, Closure $next): Response { - View::share('appearance', $request->cookie('appearance') ?? 'system'); + View::share('appearance', $request->cookie('appearance') ?? 'light'); return $next($request); } diff --git a/app/Inlay/Resources/Blog/AuthorCreate.php b/app/Inlay/Resources/Blog/AuthorCreate.php new file mode 100644 index 0000000..b8f8d4a --- /dev/null +++ b/app/Inlay/Resources/Blog/AuthorCreate.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search authors…')->filters([ + BooleanFilter::make('active')->label('Active authors'), + ])->columns([ + TextColumn::make('name')->searchable()->sortable(), + TextColumn::make('email')->searchable()->copyable(), + IconColumn::make('active')->boolean()->trueIcon('check')->falseIcon('x'), + TextColumn::make('created_at')->label('Joined')->date('M j, Y')->sortable(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/authors/{id}')->method('get'), + Action::make('edit')->url('/admin/authors/{id}/edit')->method('get'), + Action::make('delete')->color('danger')->url('/admin/authors/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save author')->schema([ + Section::make('author')->label('Author profile')->schema([ + Grid::make(2)->schema([ + TextInput::make('name')->required()->autofocus(), + TextInput::make('email')->email()->required(), + Toggle::make('active')->label('Accepting new posts'), + ]), + Textarea::make('bio')->rows(6)->maxLength(2000), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('name')->label('Author'), + TextEntry::make('email')->copyable(), + TextEntry::make('active')->badge()->color('success'), + TextEntry::make('bio')->columnSpanFull()->prose(), + ]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => AuthorList::route('/'), 'create' => AuthorCreate::route('/create'), 'view' => AuthorView::route('/{record}'), 'edit' => AuthorEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Blog/AuthorView.php b/app/Inlay/Resources/Blog/AuthorView.php new file mode 100644 index 0000000..66f1086 --- /dev/null +++ b/app/Inlay/Resources/Blog/AuthorView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search categories…')->columns([ + TextColumn::make('name')->searchable()->sortable(), TextColumn::make('slug')->copyable()->sortable(), TextColumn::make('description')->limit(64)->wrap(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/categories/{id}')->method('get'), Action::make('edit')->url('/admin/categories/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/categories/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save category')->schema([Section::make('category')->label('Category details')->schema([ + TextInput::make('name')->required()->autofocus(), TextInput::make('slug')->required()->helperText('Lowercase URL slug, for example tutorials.'), Textarea::make('description')->rows(5), + ])]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->schema([TextEntry::make('name')->label('Category'), TextEntry::make('slug')->copyable(), TextEntry::make('description')->prose()]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => CategoryList::route('/'), 'create' => CategoryCreate::route('/create'), 'view' => CategoryView::route('/{record}'), 'edit' => CategoryEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Blog/CategoryView.php b/app/Inlay/Resources/Blog/CategoryView.php new file mode 100644 index 0000000..8234cdb --- /dev/null +++ b/app/Inlay/Resources/Blog/CategoryView.php @@ -0,0 +1,12 @@ +sortable(), ]) ->actions([ + Action::make('view') + ->label('View') + ->url('/admin/blogs/{id}') + ->method('get'), Action::make('edit') ->label('Edit') ->url('/admin/blogs/{id}/edit') @@ -139,11 +149,25 @@ public static function validation(): string return BlogRules::class; } + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('title')->label('Title')->columnSpanFull(), + TextEntry::make('slug')->copyable(), + TextEntry::make('status')->badge()->color('success'), + TextEntry::make('published_at')->dateTime('M j, Y H:i')->placeholder('Not published'), + TextEntry::make('featured')->badge()->color('warning'), + TextEntry::make('excerpt')->columnSpanFull()->wrap(), + TextEntry::make('body')->label('Body')->prose()->columnSpanFull(), + ]); + } + public static function getPages(): array { return [ 'index' => ListBlogs::route('/'), 'create' => CreateBlog::route('/create'), + 'view' => ViewBlog::route('/{record}'), 'edit' => EditBlog::route('/{record}/edit'), ]; } diff --git a/app/Inlay/Resources/CreateBlog.php b/app/Inlay/Resources/CreateBlog.php index f66906a..01298fe 100644 --- a/app/Inlay/Resources/CreateBlog.php +++ b/app/Inlay/Resources/CreateBlog.php @@ -10,5 +10,5 @@ final class CreateBlog extends CreateRecord { protected static string $resource = BlogResource::class; - protected static string $component = 'blogs/form'; + protected static string $component = 'inlay/resource/form'; } diff --git a/app/Inlay/Resources/EditBlog.php b/app/Inlay/Resources/EditBlog.php index 4fabb41..e2c4837 100644 --- a/app/Inlay/Resources/EditBlog.php +++ b/app/Inlay/Resources/EditBlog.php @@ -10,5 +10,5 @@ final class EditBlog extends EditRecord { protected static string $resource = BlogResource::class; - protected static string $component = 'blogs/form'; + protected static string $component = 'inlay/resource/form'; } diff --git a/app/Inlay/Resources/Hr/DepartmentCreate.php b/app/Inlay/Resources/Hr/DepartmentCreate.php new file mode 100644 index 0000000..e9b4a1a --- /dev/null +++ b/app/Inlay/Resources/Hr/DepartmentCreate.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search departments…')->columns([ + TextColumn::make('name')->searchable()->sortable(), TextColumn::make('parent')->placeholder('Top level'), TextColumn::make('head')->searchable(), BadgeColumn::make('status')->colors(['active' => 'success', 'archived' => 'gray']), + ])->actions([ + Action::make('view')->label('View')->url('/admin/departments/{id}')->method('get'), Action::make('edit')->url('/admin/departments/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/departments/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save department')->schema([Section::make('department')->label('Department details')->schema([Grid::make(2)->schema([ + TextInput::make('name')->required()->autofocus(), TextInput::make('parent')->label('Parent department'), TextInput::make('head')->label('Department head'), Select::make('status')->options(['active' => 'Active', 'archived' => 'Archived'])->required(), + ])])]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([TextEntry::make('name')->label('Department'), TextEntry::make('parent')->placeholder('Top level'), TextEntry::make('head'), TextEntry::make('status')->badge()->color('success')]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => DepartmentList::route('/'), 'create' => DepartmentCreate::route('/create'), 'view' => DepartmentView::route('/{record}'), 'edit' => DepartmentEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Hr/DepartmentView.php b/app/Inlay/Resources/Hr/DepartmentView.php new file mode 100644 index 0000000..be65fa4 --- /dev/null +++ b/app/Inlay/Resources/Hr/DepartmentView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search employees…')->filters([ + SelectFilter::make('department')->options(['Engineering' => 'Engineering', 'Design' => 'Design', 'Content' => 'Content', 'Operations' => 'Operations', 'Sales' => 'Sales']), + SelectFilter::make('status')->options(['active' => 'Active', 'on-leave' => 'On leave', 'inactive' => 'Inactive']), + ])->columns([ + TextColumn::make('name')->searchable()->sortable(), TextColumn::make('email')->searchable()->copyable(), TextColumn::make('department')->sortable(), BadgeColumn::make('status')->colors(['active' => 'success', 'on-leave' => 'warning', 'inactive' => 'gray']), TextColumn::make('hire_date')->date('M j, Y')->sortable(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/employees/{id}')->method('get'), Action::make('edit')->url('/admin/employees/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/employees/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save employee')->schema([ + Section::make('personal')->label('Personal and employment details')->schema([ + Grid::make(2)->schema([ + TextInput::make('name')->required()->autofocus(), TextInput::make('email')->email()->required(), TextInput::make('department')->required(), Select::make('employment_type')->options(['full-time' => 'Full-time', 'part-time' => 'Part-time', 'contract' => 'Contract'])->required(), Select::make('status')->options(['active' => 'Active', 'on-leave' => 'On leave', 'inactive' => 'Inactive'])->required(), DatePicker::make('hire_date')->required(), TextInput::make('salary')->numeric()->prefix('$'), + ]), + CheckboxList::make('skills')->label('Skills')->options(['Laravel' => 'Laravel', 'React' => 'React', 'Design' => 'Design', 'SQL' => 'SQL', 'Writing' => 'Writing', 'Leadership' => 'Leadership']), + KeyValue::make('metadata')->label('Metadata')->keyLabel('Property')->valueLabel('Value')->addActionLabel('Add metadata'), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('name')->label('Employee'), TextEntry::make('email')->copyable(), TextEntry::make('department'), TextEntry::make('employment_type')->label('Employment type'), TextEntry::make('status')->badge()->color('success'), TextEntry::make('hire_date')->date('M j, Y'), TextEntry::make('salary')->money('USD'), TextEntry::make('skills')->list()->bulleted()->columnSpanFull(), TextEntry::make('metadata')->label('Metadata')->columnSpanFull(), + ]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => EmployeeList::route('/'), 'create' => EmployeeCreate::route('/create'), 'view' => EmployeeView::route('/{record}'), 'edit' => EmployeeEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Hr/EmployeeView.php b/app/Inlay/Resources/Hr/EmployeeView.php new file mode 100644 index 0000000..80eaf4e --- /dev/null +++ b/app/Inlay/Resources/Hr/EmployeeView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search expenses…')->columns([ + TextColumn::make('employee')->searchable()->sortable(), TextColumn::make('category')->sortable(), BadgeColumn::make('status')->colors(['submitted' => 'warning', 'approved' => 'info', 'rejected' => 'danger', 'reimbursed' => 'success']), TextColumn::make('amount')->money('USD')->sortable()->alignment('right'), TextColumn::make('submitted_at')->since()->sortable(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/expenses/{id}')->method('get'), Action::make('edit')->url('/admin/expenses/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/expenses/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save expense')->schema([ + Section::make('expense')->label('Expense claim')->schema([ + Grid::make(2)->schema([ + TextInput::make('employee')->required()->autofocus(), Select::make('category')->options(['Travel' => 'Travel', 'Equipment' => 'Equipment', 'Meals' => 'Meals', 'Training' => 'Training', 'Software' => 'Software'])->required(), Select::make('status')->options(['submitted' => 'Submitted', 'approved' => 'Approved', 'rejected' => 'Rejected', 'reimbursed' => 'Reimbursed'])->required(), TextInput::make('amount')->numeric()->prefix('$')->required(), DateTimePicker::make('submitted_at')->required(), DateTimePicker::make('approved_at'), + ]), + Repeater::make('line_items')->label('Expense line items')->table([TableColumn::make('Description'), TableColumn::make('Amount')])->schema([TextInput::make('description')->required(), TextInput::make('amount')->numeric()->prefix('$')->required()])->addActionLabel('Add line item'), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('employee')->label('Employee'), TextEntry::make('category'), TextEntry::make('status')->badge()->color('success'), TextEntry::make('amount')->money('USD'), TextEntry::make('submitted_at')->dateTime('M j, Y H:i'), TextEntry::make('approved_at')->dateTime('M j, Y H:i')->placeholder('Not approved'), TextEntry::make('line_items')->label('Line items')->list()->bulleted()->columnSpanFull(), + ]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => ExpenseList::route('/'), 'create' => ExpenseCreate::route('/create'), 'view' => ExpenseView::route('/{record}'), 'edit' => ExpenseEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Hr/ExpenseView.php b/app/Inlay/Resources/Hr/ExpenseView.php new file mode 100644 index 0000000..7c8d82b --- /dev/null +++ b/app/Inlay/Resources/Hr/ExpenseView.php @@ -0,0 +1,12 @@ +label('All')->badge(fn (): int => LeaveRequest::query()->count())->default(), + PageTab::make('pending')->label('Needs review')->badge(fn (): int => LeaveRequest::query()->where('status', 'pending')->count())->modifyQueryUsing(fn (Builder $query): Builder => $query->where('status', 'pending')), + PageTab::make('approved')->label('Approved')->modifyQueryUsing(fn (Builder $query): Builder => $query->where('status', 'approved')), + ]; + } +} diff --git a/app/Inlay/Resources/Hr/LeaveRequestResource.php b/app/Inlay/Resources/Hr/LeaveRequestResource.php new file mode 100644 index 0000000..61aabed --- /dev/null +++ b/app/Inlay/Resources/Hr/LeaveRequestResource.php @@ -0,0 +1,91 @@ +searchPlaceholder('Search leave requests…')->columns([ + TextColumn::make('employee')->searchable()->sortable(), TextColumn::make('type')->sortable(), BadgeColumn::make('status')->colors(['pending' => 'warning', 'approved' => 'success', 'rejected' => 'danger']), TextColumn::make('start_date')->date('M j, Y')->sortable(), TextColumn::make('end_date')->date('M j, Y')->sortable(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/leave-requests/{id}')->method('get'), Action::make('edit')->url('/admin/leave-requests/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/leave-requests/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save leave request')->schema([ + Section::make('leave')->label('Leave request')->schema([ + Grid::make(2)->schema([ + TextInput::make('employee')->required()->autofocus(), Select::make('type')->options(['annual' => 'Annual leave', 'sick' => 'Sick leave', 'parental' => 'Parental leave', 'unpaid' => 'Unpaid leave'])->required(), Select::make('status')->options(['pending' => 'Pending', 'approved' => 'Approved', 'rejected' => 'Rejected'])->required(), DatePicker::make('start_date')->required(), DatePicker::make('end_date')->required(), + ]), Textarea::make('notes')->rows(4), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('employee')->label('Employee'), TextEntry::make('type'), TextEntry::make('status')->badge()->color('success'), TextEntry::make('start_date')->date('M j, Y'), TextEntry::make('end_date')->date('M j, Y'), TextEntry::make('notes')->columnSpanFull()->wrap(), + ]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => LeaveRequestList::route('/'), 'create' => LeaveRequestCreate::route('/create'), 'view' => LeaveRequestView::route('/{record}'), 'edit' => LeaveRequestEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Hr/LeaveRequestView.php b/app/Inlay/Resources/Hr/LeaveRequestView.php new file mode 100644 index 0000000..e8eca1f --- /dev/null +++ b/app/Inlay/Resources/Hr/LeaveRequestView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search projects…')->filters([ + SelectFilter::make('status')->options(['planned' => 'Planned', 'in-progress' => 'In progress', 'at-risk' => 'At risk', 'completed' => 'Completed']), + ])->columns([ + TextColumn::make('name')->searchable()->sortable(), BadgeColumn::make('status')->colors(['planned' => 'gray', 'in-progress' => 'info', 'at-risk' => 'warning', 'completed' => 'success']), TextColumn::make('owner')->searchable()->sortable(), TextColumn::make('budget')->money('USD')->sortable()->alignment('right'), TextColumn::make('due_date')->date('M j, Y')->sortable(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/projects/{id}')->method('get'), Action::make('edit')->url('/admin/projects/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/projects/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save project')->schema([ + Section::make('project')->label('Project plan')->schema([ + Grid::make(2)->schema([ + TextInput::make('name')->required()->autofocus(), Select::make('status')->options(['planned' => 'Planned', 'in-progress' => 'In progress', 'at-risk' => 'At risk', 'completed' => 'Completed'])->required(), TextInput::make('owner')->required(), TextInput::make('budget')->numeric()->prefix('$')->required(), DatePicker::make('due_date'), + ]), + Repeater::make('plan')->label('Milestones')->table([TableColumn::make('Type'), TableColumn::make('Title'), TableColumn::make('Owner')])->schema([ + Select::make('type')->options(['milestone' => 'Milestone', 'checkpoint' => 'Checkpoint', 'task-group' => 'Task group'])->required(), TextInput::make('title')->required(), TextInput::make('owner'), + ])->addActionLabel('Add milestone')->reorderable(), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('name')->label('Project'), TextEntry::make('status')->badge()->color('success'), TextEntry::make('owner'), TextEntry::make('budget')->money('USD'), TextEntry::make('due_date')->date('M j, Y'), TextEntry::make('plan')->label('Plan')->list()->bulleted()->columnSpanFull(), + ]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => ProjectList::route('/'), 'create' => ProjectCreate::route('/create'), 'view' => ProjectView::route('/{record}'), 'edit' => ProjectEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Hr/ProjectView.php b/app/Inlay/Resources/Hr/ProjectView.php new file mode 100644 index 0000000..926a905 --- /dev/null +++ b/app/Inlay/Resources/Hr/ProjectView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search tasks…')->columns([ + TextColumn::make('title')->searchable()->sortable()->limit(56), TextColumn::make('project')->searchable()->sortable(), TextColumn::make('assignee')->searchable(), BadgeColumn::make('priority')->colors(['low' => 'gray', 'normal' => 'info', 'high' => 'warning', 'urgent' => 'danger']), BadgeColumn::make('status')->colors(['todo' => 'gray', 'in-progress' => 'info', 'blocked' => 'danger', 'done' => 'success']), TextColumn::make('due_date')->date('M j, Y')->sortable(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/tasks/{id}')->method('get'), Action::make('edit')->url('/admin/tasks/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/tasks/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save task')->schema([Section::make('task')->label('Task details')->schema([Grid::make(2)->schema([ + TextInput::make('title')->required()->autofocus(), TextInput::make('project'), TextInput::make('assignee'), Select::make('priority')->options(['low' => 'Low', 'normal' => 'Normal', 'high' => 'High', 'urgent' => 'Urgent'])->required(), Select::make('status')->options(['todo' => 'To do', 'in-progress' => 'In progress', 'blocked' => 'Blocked', 'done' => 'Done'])->required(), DatePicker::make('due_date'), TextInput::make('estimate')->numeric()->suffix('hours'), + ])])]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([TextEntry::make('title')->label('Task')->columnSpanFull(), TextEntry::make('project'), TextEntry::make('assignee'), TextEntry::make('priority')->badge()->color('warning'), TextEntry::make('status')->badge()->color('success'), TextEntry::make('due_date')->date('M j, Y'), TextEntry::make('estimate')->suffix(' hours')]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => TaskList::route('/'), 'create' => TaskCreate::route('/create'), 'view' => TaskView::route('/{record}'), 'edit' => TaskEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Hr/TaskView.php b/app/Inlay/Resources/Hr/TaskView.php new file mode 100644 index 0000000..4babedd --- /dev/null +++ b/app/Inlay/Resources/Hr/TaskView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search timesheets…')->columns([ + TextColumn::make('employee')->searchable()->sortable(), TextColumn::make('project')->searchable(), TextColumn::make('work_date')->date('M j, Y')->sortable(), TextColumn::make('hours')->numeric()->suffix(' h')->sortable()->alignment('right'), BadgeColumn::make('status')->colors(['draft' => 'gray', 'submitted' => 'warning', 'approved' => 'success']), + ])->actions([ + Action::make('view')->label('View')->url('/admin/timesheets/{id}')->method('get'), Action::make('edit')->url('/admin/timesheets/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/timesheets/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save timesheet')->schema([Section::make('timesheet')->label('Timesheet entry')->schema([Grid::make(2)->schema([ + TextInput::make('employee')->required()->autofocus(), TextInput::make('project'), DatePicker::make('work_date')->required(), TextInput::make('hours')->numeric()->suffix(' hours')->required(), Select::make('status')->options(['draft' => 'Draft', 'submitted' => 'Submitted', 'approved' => 'Approved'])->required(), + ]), Textarea::make('notes')->rows(4)])]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([TextEntry::make('employee')->label('Employee'), TextEntry::make('project'), TextEntry::make('work_date')->date('M j, Y'), TextEntry::make('hours')->suffix(' hours'), TextEntry::make('status')->badge()->color('success'), TextEntry::make('notes')->columnSpanFull()->wrap()]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => TimesheetList::route('/'), 'create' => TimesheetCreate::route('/create'), 'view' => TimesheetView::route('/{record}'), 'edit' => TimesheetEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Hr/TimesheetView.php b/app/Inlay/Resources/Hr/TimesheetView.php new file mode 100644 index 0000000..9571dc1 --- /dev/null +++ b/app/Inlay/Resources/Hr/TimesheetView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search brands…')->reorderable('sort')->columns([ + TextColumn::make('name')->searchable()->sortable(), TextColumn::make('slug')->copyable()->sortable(), BadgeColumn::make('status')->colors(['active' => 'success', 'inactive' => 'gray']), TextColumn::make('website')->url(fn (string $state): string => $state)->openUrlInNewTab(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/brands/{id}')->method('get'), Action::make('edit')->url('/admin/brands/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/brands/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save brand')->schema([Section::make('brand')->label('Brand details')->schema([ + Grid::make(2)->schema([TextInput::make('name')->required()->autofocus(), TextInput::make('slug')->required(), Select::make('status')->options(['active' => 'Active', 'inactive' => 'Inactive'])->required(), TextInput::make('website')->url(), TextInput::make('sort')->numeric()->required()]), + ])]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([TextEntry::make('name')->label('Brand'), TextEntry::make('slug')->copyable(), TextEntry::make('status')->badge()->color('success'), TextEntry::make('website')->url(), TextEntry::make('sort')->numeric()]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => BrandList::route('/'), 'create' => BrandCreate::route('/create'), 'view' => BrandView::route('/{record}'), 'edit' => BrandEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Shop/BrandView.php b/app/Inlay/Resources/Shop/BrandView.php new file mode 100644 index 0000000..587551f --- /dev/null +++ b/app/Inlay/Resources/Shop/BrandView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search customers…') + ->filters([SelectFilter::make('status')->options(['active' => 'Active', 'inactive' => 'Inactive'])]) + ->columns([ + TextColumn::make('name')->searchable()->sortable(), + TextColumn::make('email')->searchable()->copyable(), + TextColumn::make('phone')->placeholder('Not provided'), + BadgeColumn::make('status')->colors(['active' => 'success', 'inactive' => 'gray']), + TextColumn::make('created_at')->label('Joined')->date('M j, Y')->sortable(), + ]) + ->actions([ + Action::make('view')->label('View')->url('/admin/customers/{id}')->method('get'), + Action::make('edit')->url('/admin/customers/{id}/edit')->method('get'), + Action::make('delete')->color('danger')->url('/admin/customers/{id}')->method('delete')->requiresConfirmation(), + ]) + ->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save customer')->schema([ + Section::make('profile')->label('Customer profile')->schema([ + Grid::make(2)->schema([ + TextInput::make('name')->required()->autofocus(), + TextInput::make('email')->email()->required(), + TextInput::make('phone')->tel()->telRegex('/^\\+?[0-9 ()-]{7,}$/'), + Select::make('status')->options(['active' => 'Active', 'inactive' => 'Inactive'])->required(), + ]), + Textarea::make('notes')->rows(4)->helperText('Internal notes are visible to the shop team.'), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('name')->label('Customer'), + TextEntry::make('email')->copyable(), + TextEntry::make('phone')->placeholder('Not provided'), + TextEntry::make('status')->badge()->color('success'), + TextEntry::make('notes')->columnSpanFull()->wrap(), + ]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return [ + 'index' => CustomerList::route('/'), + 'create' => CustomerCreate::route('/create'), + 'view' => CustomerView::route('/{record}'), + 'edit' => CustomerEdit::route('/{record}/edit'), + ]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Shop/CustomerView.php b/app/Inlay/Resources/Shop/CustomerView.php new file mode 100644 index 0000000..59d6b0c --- /dev/null +++ b/app/Inlay/Resources/Shop/CustomerView.php @@ -0,0 +1,12 @@ +label('All orders')->badge(fn (): int => Order::query()->count())->default(), + PageTab::make('pending')->label('Pending')->badge(fn (): int => Order::query()->where('status', 'pending')->count())->modifyQueryUsing(fn (Builder $query): Builder => $query->where('status', 'pending')), + PageTab::make('paid')->label('Paid')->modifyQueryUsing(fn (Builder $query): Builder => $query->where('status', 'paid')), + PageTab::make('shipped')->label('Shipped')->modifyQueryUsing(fn (Builder $query): Builder => $query->where('status', 'shipped')), + ]; + } +} diff --git a/app/Inlay/Resources/Shop/OrderResource.php b/app/Inlay/Resources/Shop/OrderResource.php new file mode 100644 index 0000000..87d0ac7 --- /dev/null +++ b/app/Inlay/Resources/Shop/OrderResource.php @@ -0,0 +1,134 @@ +searchPlaceholder('Search order number…') + ->filters([SelectFilter::make('status')->options([ + 'pending' => 'Pending', 'paid' => 'Paid', 'shipped' => 'Shipped', 'refunded' => 'Refunded', 'cancelled' => 'Cancelled', + ])]) + ->columns([ + TextColumn::make('number')->label('Order')->searchable()->sortable()->copyable(), + TextColumn::make('customer.name')->label('Customer')->searchable()->sortable()->placeholder('Guest'), + BadgeColumn::make('status')->colors(['pending' => 'warning', 'paid' => 'success', 'shipped' => 'info', 'refunded' => 'gray', 'cancelled' => 'danger']), + TextColumn::make('total')->money('USD')->sortable()->alignment('right'), + TextColumn::make('placed_at')->dateTime('M j, Y H:i')->sortable(), + ]) + ->actions([ + Action::make('view')->label('View')->url('/admin/orders/{id}')->method('get'), + Action::make('edit')->url('/admin/orders/{id}/edit')->method('get'), + Action::make('delete')->color('danger')->url('/admin/orders/{id}')->method('delete')->requiresConfirmation(), + ]) + ->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save order')->schema([ + Section::make('order')->label('Order details')->schema([ + Grid::make(2)->schema([ + TextInput::make('number')->label('Order number')->required()->maxLength(30), + Select::make('customer_id')->label('Customer')->options(fn (): array => Customer::query()->orderBy('name')->pluck('name', 'id')->all())->searchable()->placeholder('Guest checkout'), + Select::make('status')->options(['pending' => 'Pending', 'paid' => 'Paid', 'shipped' => 'Shipped', 'refunded' => 'Refunded', 'cancelled' => 'Cancelled'])->required(), + Select::make('payment_method')->options(['card' => 'Card', 'bank_transfer' => 'Bank transfer', 'cash' => 'Cash'])->required(), + TextInput::make('total')->numeric()->prefix('$')->required(), + TextInput::make('placed_at')->label('Placed at')->required()->helperText('Use a date/time accepted by Laravel.'), + ]), + Repeater::make('items')->label('Line items')->table([ + TableColumn::make('Item'), + TableColumn::make('Qty'), + TableColumn::make('Price'), + ])->schema([ + TextInput::make('name')->required(), + TextInput::make('quantity')->numeric()->required(), + TextInput::make('price')->numeric()->prefix('$')->required(), + ])->minItems(1)->addActionLabel('Add line item'), + Textarea::make('notes')->rows(3), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('number')->label('Order')->copyable(), + TextEntry::make('customer.name')->label('Customer')->placeholder('Guest checkout'), + TextEntry::make('status')->badge()->color('success'), + TextEntry::make('payment_method')->label('Payment method'), + TextEntry::make('total')->money('USD'), + TextEntry::make('placed_at')->dateTime('M j, Y H:i'), + TextEntry::make('items')->label('Line items')->list()->bulleted()->columnSpanFull(), + TextEntry::make('notes')->columnSpanFull()->wrap(), + ]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return [ + 'index' => OrderList::route('/'), + 'create' => OrderCreate::route('/create'), + 'view' => OrderView::route('/{record}'), + 'edit' => OrderEdit::route('/{record}/edit'), + ]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Shop/OrderView.php b/app/Inlay/Resources/Shop/OrderView.php new file mode 100644 index 0000000..70cde4f --- /dev/null +++ b/app/Inlay/Resources/Shop/OrderView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search product categories…')->columns([ + TextColumn::make('name')->searchable()->sortable(), TextColumn::make('slug')->copyable()->sortable(), TextColumn::make('description')->limit(64)->wrap(), + ])->actions([ + Action::make('view')->label('View')->url('/admin/product-categories/{id}')->method('get'), Action::make('edit')->url('/admin/product-categories/{id}/edit')->method('get'), Action::make('delete')->color('danger')->url('/admin/product-categories/{id}')->method('delete')->requiresConfirmation(), + ])->paginationPageOptions([10, 25, 50]); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save category')->schema([Section::make('category')->label('Product category')->schema([TextInput::make('name')->required()->autofocus(), TextInput::make('slug')->required(), Textarea::make('description')->rows(5)])]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->schema([TextEntry::make('name')->label('Category'), TextEntry::make('slug')->copyable(), TextEntry::make('description')->prose()]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return ['index' => ProductCategoryList::route('/'), 'create' => ProductCategoryCreate::route('/create'), 'view' => ProductCategoryView::route('/{record}'), 'edit' => ProductCategoryEdit::route('/{record}/edit')]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Shop/ProductCategoryView.php b/app/Inlay/Resources/Shop/ProductCategoryView.php new file mode 100644 index 0000000..50dee5f --- /dev/null +++ b/app/Inlay/Resources/Shop/ProductCategoryView.php @@ -0,0 +1,12 @@ +searchPlaceholder('Search products…') + ->filters([ + SelectFilter::make('status')->label('Status')->options([ + 'active' => 'Active', 'draft' => 'Draft', 'archived' => 'Archived', + ]), + ]) + ->columns([ + TextColumn::make('name')->searchable()->sortable()->limit(46), + TextColumn::make('sku')->label('SKU')->copyable()->sortable(), + BadgeColumn::make('status')->colors(['active' => 'success', 'draft' => 'warning', 'archived' => 'gray']), + TextColumn::make('price')->money('USD')->sortable()->alignment('right'), + TextColumn::make('stock')->numeric()->sortable()->alignment('right'), + ]) + ->actions([ + Action::make('view')->label('View')->url('/admin/products/{id}')->method('get'), + Action::make('edit')->label('Edit')->url('/admin/products/{id}/edit')->method('get'), + Action::make('delete')->label('Delete')->color('danger')->url('/admin/products/{id}')->method('delete')->requiresConfirmation(), + ]) + ->paginationPageOptions([10, 25, 50]) + ->emptyState('No products found', 'Create the first product for the showcase.'); + } + + public static function form(Form $form): Form + { + return $form->submitLabel('Save product')->schema([ + Section::make('product')->label('Product details')->description('A PHP-defined product form with validation, badges, and stock data.')->schema([ + Grid::make(2)->schema([ + TextInput::make('name')->required()->autofocus()->maxLength(255), + TextInput::make('sku')->label('SKU')->required()->maxLength(80), + Select::make('status')->options(['active' => 'Active', 'draft' => 'Draft', 'archived' => 'Archived'])->required(), + TextInput::make('price')->numeric()->prefix('$')->required(), + TextInput::make('stock')->numeric()->required(), + Toggle::make('featured')->label('Featured product'), + ]), + Textarea::make('description')->rows(5)->maxLength(2000), + ]), + ]); + } + + public static function infolist(Infolist $infolist): Infolist + { + return $infolist->columns(2)->schema([ + TextEntry::make('name')->label('Product name'), + TextEntry::make('sku')->label('SKU')->copyable(), + TextEntry::make('status')->badge()->color('success'), + TextEntry::make('price')->money('USD'), + TextEntry::make('stock')->numeric(), + TextEntry::make('description')->prose()->columnSpanFull(), + ]); + } + + public static function validation(): string + { + return ShowcaseRules::class; + } + + public static function getPages(): array + { + return [ + 'index' => ProductList::route('/'), + 'create' => ProductCreate::route('/create'), + 'view' => ProductView::route('/{record}'), + 'edit' => ProductEdit::route('/{record}/edit'), + ]; + } + + protected static function canAccess(ResourceOperation $operation, ?Model $record, mixed $user): bool + { + return $user !== null; + } +} diff --git a/app/Inlay/Resources/Shop/ProductView.php b/app/Inlay/Resources/Shop/ProductView.php new file mode 100644 index 0000000..a1704d1 --- /dev/null +++ b/app/Inlay/Resources/Shop/ProductView.php @@ -0,0 +1,12 @@ +label('At a glance') - ->columns(3) + ->columns(4) ->stats([ Stat::make('Users', User::query()->count()) ->description('Accounts with panel access') @@ -41,6 +47,22 @@ public function widgets(Request $request): iterable ->description('Files in the shared library') ->color('info') ->url('/admin/media'), + Stat::make('Orders', Order::query()->count()) + ->description(Order::query()->where('status', 'pending')->count().' pending review') + ->color('warning') + ->url('/admin/orders'), + Stat::make('Products', Product::query()->count()) + ->description(Product::query()->where('stock', '<', 10)->count().' low-stock items') + ->color('primary') + ->url('/admin/products'), + Stat::make('Employees', Employee::query()->where('status', 'active')->count()) + ->description(LeaveRequest::query()->where('status', 'pending')->count().' leave requests') + ->color('success') + ->url('/admin/employees'), + Stat::make('Open expenses', Expense::query()->whereIn('status', ['submitted', 'approved'])->count()) + ->description(Project::query()->whereIn('status', ['planned', 'in-progress', 'at-risk'])->count().' active projects') + ->color('info') + ->url('/admin/expenses'), ]); yield ChartWidget::make('content-activity') @@ -67,5 +89,34 @@ public function widgets(Request $request): iterable ->rows(Blog::query()->latest()->limit(5)->get(['id', 'title', 'status']))) ->columnSpan(5) ->sort(20); + + yield TableWidget::make('recent-orders') + ->label('Latest orders') + ->description('Orders from the shop module, rendered by the same Table contract.') + ->table(Table::make('dashboard_orders') + ->columns([ + TextColumn::make('number')->label('Order'), + TextColumn::make('customer.name')->label('Customer')->placeholder('Guest'), + BadgeColumn::make('status')->colors(['pending' => 'warning', 'paid' => 'success', 'shipped' => 'info', 'refunded' => 'gray', 'cancelled' => 'danger']), + TextColumn::make('total')->money('USD')->alignment('right'), + ]) + ->rows(Order::query()->with('customer')->latest('placed_at')->limit(6)->get())) + ->columnSpan(7) + ->sort(30); + + yield ChartWidget::make('people-workload') + ->label('People and work') + ->description('A small HR overview powered by database queries in PHP.') + ->chartType('bar') + ->labels(['Active', 'On leave', 'Pending leave', 'Active projects', 'Open expenses']) + ->dataset('Records', [ + Employee::query()->where('status', 'active')->count(), + Employee::query()->where('status', 'on-leave')->count(), + LeaveRequest::query()->where('status', 'pending')->count(), + Project::query()->whereIn('status', ['planned', 'in-progress', 'at-risk'])->count(), + Expense::query()->whereIn('status', ['submitted', 'approved'])->count(), + ], '#047857') + ->columnSpan(5) + ->sort(40); } } diff --git a/app/Models/Blog/Author.php b/app/Models/Blog/Author.php new file mode 100644 index 0000000..de7cdb0 --- /dev/null +++ b/app/Models/Blog/Author.php @@ -0,0 +1,24 @@ + */ + use HasFactory; + + protected function casts(): array + { + return ['active' => 'boolean']; + } +} diff --git a/app/Models/Blog/Category.php b/app/Models/Blog/Category.php new file mode 100644 index 0000000..f603bd5 --- /dev/null +++ b/app/Models/Blog/Category.php @@ -0,0 +1,19 @@ + */ + use HasFactory; +} diff --git a/app/Models/Hr/Department.php b/app/Models/Hr/Department.php new file mode 100644 index 0000000..58ac584 --- /dev/null +++ b/app/Models/Hr/Department.php @@ -0,0 +1,19 @@ + */ + use HasFactory; +} diff --git a/app/Models/Hr/Employee.php b/app/Models/Hr/Employee.php new file mode 100644 index 0000000..049c4af --- /dev/null +++ b/app/Models/Hr/Employee.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'hire_date' => 'date', + 'salary' => 'decimal:2', + 'skills' => 'array', + 'metadata' => 'array', + ]; + } +} diff --git a/app/Models/Hr/Expense.php b/app/Models/Hr/Expense.php new file mode 100644 index 0000000..3190fe1 --- /dev/null +++ b/app/Models/Hr/Expense.php @@ -0,0 +1,29 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'amount' => 'decimal:2', + 'submitted_at' => 'datetime', + 'approved_at' => 'datetime', + 'line_items' => 'array', + ]; + } +} diff --git a/app/Models/Hr/LeaveRequest.php b/app/Models/Hr/LeaveRequest.php new file mode 100644 index 0000000..e5c707c --- /dev/null +++ b/app/Models/Hr/LeaveRequest.php @@ -0,0 +1,27 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'start_date' => 'date', + 'end_date' => 'date', + ]; + } +} diff --git a/app/Models/Hr/Project.php b/app/Models/Hr/Project.php new file mode 100644 index 0000000..3d27e40 --- /dev/null +++ b/app/Models/Hr/Project.php @@ -0,0 +1,28 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'budget' => 'decimal:2', + 'due_date' => 'date', + 'plan' => 'array', + ]; + } +} diff --git a/app/Models/Hr/Task.php b/app/Models/Hr/Task.php new file mode 100644 index 0000000..01ca0aa --- /dev/null +++ b/app/Models/Hr/Task.php @@ -0,0 +1,24 @@ + */ + use HasFactory; + + protected function casts(): array + { + return ['due_date' => 'date', 'estimate' => 'decimal:2']; + } +} diff --git a/app/Models/Hr/Timesheet.php b/app/Models/Hr/Timesheet.php new file mode 100644 index 0000000..7626896 --- /dev/null +++ b/app/Models/Hr/Timesheet.php @@ -0,0 +1,24 @@ + */ + use HasFactory; + + protected function casts(): array + { + return ['work_date' => 'date', 'hours' => 'decimal:2']; + } +} diff --git a/app/Models/Shop/Brand.php b/app/Models/Shop/Brand.php new file mode 100644 index 0000000..3f869eb --- /dev/null +++ b/app/Models/Shop/Brand.php @@ -0,0 +1,19 @@ + */ + use HasFactory; +} diff --git a/app/Models/Shop/Customer.php b/app/Models/Shop/Customer.php new file mode 100644 index 0000000..c2d91ef --- /dev/null +++ b/app/Models/Shop/Customer.php @@ -0,0 +1,19 @@ + */ + use HasFactory; +} diff --git a/app/Models/Shop/Order.php b/app/Models/Shop/Order.php new file mode 100644 index 0000000..e05ae37 --- /dev/null +++ b/app/Models/Shop/Order.php @@ -0,0 +1,35 @@ + */ + use HasFactory; + + /** @return BelongsTo */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + protected function casts(): array + { + return [ + 'total' => 'decimal:2', + 'placed_at' => 'datetime', + 'items' => 'array', + ]; + } +} diff --git a/app/Models/Shop/Product.php b/app/Models/Shop/Product.php new file mode 100644 index 0000000..1919177 --- /dev/null +++ b/app/Models/Shop/Product.php @@ -0,0 +1,28 @@ + */ + use HasFactory; + + protected function casts(): array + { + return [ + 'price' => 'decimal:2', + 'stock' => 'integer', + 'featured' => 'boolean', + ]; + } +} diff --git a/app/Models/Shop/ProductCategory.php b/app/Models/Shop/ProductCategory.php new file mode 100644 index 0000000..55d03a7 --- /dev/null +++ b/app/Models/Shop/ProductCategory.php @@ -0,0 +1,19 @@ + */ + use HasFactory; +} diff --git a/app/Providers/Inlay/AdminPanelProvider.php b/app/Providers/Inlay/AdminPanelProvider.php index 2f7b261..263d7b3 100644 --- a/app/Providers/Inlay/AdminPanelProvider.php +++ b/app/Providers/Inlay/AdminPanelProvider.php @@ -4,7 +4,21 @@ namespace App\Providers\Inlay; +use App\Inlay\Resources\Blog\AuthorResource; +use App\Inlay\Resources\Blog\CategoryResource; use App\Inlay\Resources\BlogResource; +use App\Inlay\Resources\Hr\DepartmentResource; +use App\Inlay\Resources\Hr\EmployeeResource; +use App\Inlay\Resources\Hr\ExpenseResource; +use App\Inlay\Resources\Hr\LeaveRequestResource; +use App\Inlay\Resources\Hr\ProjectResource; +use App\Inlay\Resources\Hr\TaskResource; +use App\Inlay\Resources\Hr\TimesheetResource; +use App\Inlay\Resources\Shop\BrandResource; +use App\Inlay\Resources\Shop\CustomerResource; +use App\Inlay\Resources\Shop\OrderResource; +use App\Inlay\Resources\Shop\ProductCategoryResource; +use App\Inlay\Resources\Shop\ProductResource; use App\Inlay\Resources\UserResource; use App\Inlay\Widgets\AdminDashboardWidgets; use Inlay\MediaManager\MediaManagerPlugin; @@ -36,7 +50,7 @@ public function panel(Panel $panel): Panel ->breadcrumbs() ->topbar() ->spa() - ->globalSearch(false) + ->globalSearch() ->middleware(['web']) ->authMiddleware(['auth']) ->loginComponent('inlay/auth/login') @@ -45,9 +59,27 @@ public function panel(Panel $panel): Panel ->resources([ UserResource::class, BlogResource::class, + ProductResource::class, + CustomerResource::class, + OrderResource::class, + BrandResource::class, + ProductCategoryResource::class, + AuthorResource::class, + CategoryResource::class, + DepartmentResource::class, + EmployeeResource::class, + ProjectResource::class, + LeaveRequestResource::class, + ExpenseResource::class, + TaskResource::class, + TimesheetResource::class, ]) ->widget(AdminDashboardWidgets::class) ->navigationGroups([ + NavigationGroup::make('Shop')->label('Shop')->icon('shopping-bag')->sort(10), + NavigationGroup::make('Blog')->label('Blog')->icon('newspaper')->sort(20), + NavigationGroup::make('HR')->label('HR & projects')->icon('briefcase-business')->sort(30), + NavigationGroup::make('Administration')->label('Administration')->icon('settings')->sort(40), NavigationGroup::make('examples') ->label('Package demos') ->icon('braces') diff --git a/app/Validation/ShowcaseRules.php b/app/Validation/ShowcaseRules.php new file mode 100644 index 0000000..95ecf15 --- /dev/null +++ b/app/Validation/ShowcaseRules.php @@ -0,0 +1,148 @@ +option('resource'); + + return match (true) { + str_ends_with($resource, '\\ProductResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'sku' => ['required', 'string', 'max:80', Rule::unique('shop_products', 'sku')->ignore($context->record())], + 'status' => ['required', Rule::in(['active', 'draft', 'archived'])], + 'price' => ['required', 'numeric', 'min:0'], + 'stock' => ['required', 'integer', 'min:0'], + 'description' => ['nullable', 'string', 'max:2000'], + 'featured' => ['boolean'], + ], + str_ends_with($resource, '\\BrandResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('shop_brands', 'slug')->ignore($context->record())], + 'status' => ['required', Rule::in(['active', 'inactive'])], + 'website' => ['nullable', 'url', 'max:255'], + 'sort' => ['required', 'integer', 'min:0'], + ], + str_ends_with($resource, '\\ProductCategoryResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('shop_product_categories', 'slug')->ignore($context->record())], + 'description' => ['nullable', 'string', 'max:2000'], + ], + str_ends_with($resource, '\\CustomerResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('shop_customers', 'email')->ignore($context->record())], + 'phone' => ['nullable', 'string', 'max:40'], + 'status' => ['required', Rule::in(['active', 'inactive'])], + 'notes' => ['nullable', 'string', 'max:2000'], + ], + str_ends_with($resource, '\\OrderResource') => [ + 'number' => ['required', 'string', 'max:30', Rule::unique('shop_orders', 'number')->ignore($context->record())], + 'status' => ['required', Rule::in(['pending', 'paid', 'shipped', 'refunded', 'cancelled'])], + 'payment_method' => ['required', Rule::in(['card', 'bank_transfer', 'cash'])], + 'total' => ['required', 'numeric', 'min:0'], + 'placed_at' => ['required', 'date'], + 'notes' => ['nullable', 'string', 'max:2000'], + 'items' => ['nullable', 'array'], + 'items.*.name' => ['required', 'string', 'max:255'], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'items.*.price' => ['required', 'numeric', 'min:0'], + ], + str_ends_with($resource, '\\AuthorResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('blog_authors', 'email')->ignore($context->record())], + 'bio' => ['nullable', 'string', 'max:2000'], + 'active' => ['boolean'], + ], + str_ends_with($resource, '\\CategoryResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('blog_categories', 'slug')->ignore($context->record())], + 'description' => ['nullable', 'string', 'max:2000'], + ], + str_ends_with($resource, '\\EmployeeResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('hr_employees', 'email')->ignore($context->record())], + 'department' => ['required', 'string', 'max:80'], + 'employment_type' => ['required', Rule::in(['full-time', 'part-time', 'contract'])], + 'status' => ['required', Rule::in(['active', 'on-leave', 'inactive'])], + 'hire_date' => ['required', 'date'], + 'salary' => ['nullable', 'numeric', 'min:0'], + 'skills' => ['nullable', 'array'], + 'skills.*' => ['string'], + 'metadata' => ['nullable', 'array'], + ], + str_ends_with($resource, '\\DepartmentResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'parent' => ['nullable', 'string', 'max:255'], + 'head' => ['nullable', 'string', 'max:255'], + 'status' => ['required', Rule::in(['active', 'archived'])], + ], + str_ends_with($resource, '\\TaskResource') => [ + 'title' => ['required', 'string', 'max:255'], + 'project' => ['nullable', 'string', 'max:255'], + 'assignee' => ['nullable', 'string', 'max:255'], + 'priority' => ['required', Rule::in(['low', 'normal', 'high', 'urgent'])], + 'status' => ['required', Rule::in(['todo', 'in-progress', 'blocked', 'done'])], + 'due_date' => ['nullable', 'date'], + 'estimate' => ['nullable', 'numeric', 'min:0'], + ], + str_ends_with($resource, '\\TimesheetResource') => [ + 'employee' => ['required', 'string', 'max:255'], + 'project' => ['nullable', 'string', 'max:255'], + 'work_date' => ['required', 'date'], + 'hours' => ['required', 'numeric', 'min:0'], + 'status' => ['required', Rule::in(['draft', 'submitted', 'approved'])], + 'notes' => ['nullable', 'string', 'max:2000'], + ], + str_ends_with($resource, '\\ProjectResource') => [ + 'name' => ['required', 'string', 'max:255'], + 'status' => ['required', Rule::in(['planned', 'in-progress', 'at-risk', 'completed'])], + 'owner' => ['required', 'string', 'max:255'], + 'budget' => ['required', 'numeric', 'min:0'], + 'due_date' => ['nullable', 'date'], + 'plan' => ['nullable', 'array'], + ], + str_ends_with($resource, '\\LeaveRequestResource') => [ + 'employee' => ['required', 'string', 'max:255'], + 'type' => ['required', Rule::in(['annual', 'sick', 'parental', 'unpaid'])], + 'status' => ['required', Rule::in(['pending', 'approved', 'rejected'])], + 'start_date' => ['required', 'date'], + 'end_date' => ['required', 'date', 'after_or_equal:start_date'], + 'notes' => ['nullable', 'string', 'max:2000'], + ], + str_ends_with($resource, '\\ExpenseResource') => [ + 'employee' => ['required', 'string', 'max:255'], + 'category' => ['required', Rule::in(['Travel', 'Equipment', 'Meals', 'Training', 'Software'])], + 'status' => ['required', Rule::in(['submitted', 'approved', 'rejected', 'reimbursed'])], + 'amount' => ['required', 'numeric', 'min:0'], + 'submitted_at' => ['required', 'date'], + 'approved_at' => ['nullable', 'date'], + 'line_items' => ['nullable', 'array'], + ], + default => [], + }; + } + + public function prepare(array $data, ValidationContext $context): array + { + foreach ($data as $key => $value) { + if (is_string($value)) { + $data[$key] = trim($value); + } + } + + return $data; + } +} diff --git a/database/factories/Blog/AuthorFactory.php b/database/factories/Blog/AuthorFactory.php new file mode 100644 index 0000000..63c3e4b --- /dev/null +++ b/database/factories/Blog/AuthorFactory.php @@ -0,0 +1,24 @@ + */ +final class AuthorFactory extends Factory +{ + protected $model = Author::class; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'bio' => fake()->paragraph(), + 'active' => fake()->boolean(90), + ]; + } +} diff --git a/database/factories/Blog/CategoryFactory.php b/database/factories/Blog/CategoryFactory.php new file mode 100644 index 0000000..6ad4d09 --- /dev/null +++ b/database/factories/Blog/CategoryFactory.php @@ -0,0 +1,26 @@ + */ +final class CategoryFactory extends Factory +{ + protected $model = Category::class; + + public function definition(): array + { + $name = fake()->unique()->sentence(2); + + return [ + 'name' => Str::title($name), + 'slug' => Str::slug($name), + 'description' => fake()->optional()->sentence(), + ]; + } +} diff --git a/database/factories/Hr/DepartmentFactory.php b/database/factories/Hr/DepartmentFactory.php new file mode 100644 index 0000000..921d729 --- /dev/null +++ b/database/factories/Hr/DepartmentFactory.php @@ -0,0 +1,19 @@ + */ +final class DepartmentFactory extends Factory +{ + protected $model = Department::class; + + public function definition(): array + { + return ['name' => fake()->randomElement(['Engineering', 'Design', 'Content', 'Operations', 'Sales', 'People']), 'parent' => fake()->optional()->randomElement(['Corporate', 'Product', 'Customer']), 'head' => fake()->name(), 'status' => fake()->randomElement(['active', 'active', 'archived'])]; + } +} diff --git a/database/factories/Hr/EmployeeFactory.php b/database/factories/Hr/EmployeeFactory.php new file mode 100644 index 0000000..6d54555 --- /dev/null +++ b/database/factories/Hr/EmployeeFactory.php @@ -0,0 +1,29 @@ + */ +final class EmployeeFactory extends Factory +{ + protected $model = Employee::class; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'department' => fake()->randomElement(['Engineering', 'Design', 'Content', 'Operations', 'Sales']), + 'employment_type' => fake()->randomElement(['full-time', 'part-time', 'contract']), + 'status' => fake()->randomElement(['active', 'active', 'on-leave', 'inactive']), + 'hire_date' => fake()->dateTimeBetween('-6 years', '-2 months'), + 'salary' => fake()->randomFloat(2, 42000, 168000), + 'skills' => fake()->randomElements(['Laravel', 'React', 'Design', 'SQL', 'Writing', 'Leadership'], fake()->numberBetween(1, 4)), + 'metadata' => ['timezone' => fake()->randomElement(['Asia/Hong_Kong', 'Europe/London', 'America/New_York'])], + ]; + } +} diff --git a/database/factories/Hr/ExpenseFactory.php b/database/factories/Hr/ExpenseFactory.php new file mode 100644 index 0000000..e9bd717 --- /dev/null +++ b/database/factories/Hr/ExpenseFactory.php @@ -0,0 +1,31 @@ + */ +final class ExpenseFactory extends Factory +{ + protected $model = Expense::class; + + public function definition(): array + { + $submitted = fake()->dateTimeBetween('-10 months', 'now'); + + return [ + 'employee' => fake()->name(), + 'category' => fake()->randomElement(['Travel', 'Equipment', 'Meals', 'Training', 'Software']), + 'status' => fake()->randomElement(['submitted', 'approved', 'rejected', 'reimbursed']), + 'amount' => fake()->randomFloat(2, 24, 2400), + 'submitted_at' => $submitted, + 'approved_at' => fake()->optional(0.55)->dateTimeBetween($submitted, 'now'), + 'line_items' => [ + ['description' => fake()->sentence(3), 'amount' => fake()->randomFloat(2, 12, 800)], + ], + ]; + } +} diff --git a/database/factories/Hr/LeaveRequestFactory.php b/database/factories/Hr/LeaveRequestFactory.php new file mode 100644 index 0000000..a2a8bd9 --- /dev/null +++ b/database/factories/Hr/LeaveRequestFactory.php @@ -0,0 +1,28 @@ + */ +final class LeaveRequestFactory extends Factory +{ + protected $model = LeaveRequest::class; + + public function definition(): array + { + $start = fake()->dateTimeBetween('-4 months', '+3 months'); + + return [ + 'employee' => fake()->name(), + 'type' => fake()->randomElement(['annual', 'sick', 'parental', 'unpaid']), + 'status' => fake()->randomElement(['pending', 'approved', 'rejected']), + 'start_date' => $start, + 'end_date' => (clone $start)->modify('+'.fake()->numberBetween(1, 7).' days'), + 'notes' => fake()->optional()->sentence(), + ]; + } +} diff --git a/database/factories/Hr/ProjectFactory.php b/database/factories/Hr/ProjectFactory.php new file mode 100644 index 0000000..85f38cd --- /dev/null +++ b/database/factories/Hr/ProjectFactory.php @@ -0,0 +1,29 @@ + */ +final class ProjectFactory extends Factory +{ + protected $model = Project::class; + + public function definition(): array + { + return [ + 'name' => fake()->sentence(3), + 'status' => fake()->randomElement(['planned', 'in-progress', 'at-risk', 'completed']), + 'owner' => fake()->name(), + 'budget' => fake()->randomFloat(2, 5000, 250000), + 'due_date' => fake()->optional()->dateTimeBetween('now', '+9 months'), + 'plan' => [ + ['type' => 'milestone', 'data' => ['title' => 'Discovery', 'due' => now()->addWeeks(2)->toDateString()]], + ['type' => 'checkpoint', 'data' => ['title' => 'Review scope', 'owner' => fake()->name()]], + ], + ]; + } +} diff --git a/database/factories/Hr/TaskFactory.php b/database/factories/Hr/TaskFactory.php new file mode 100644 index 0000000..4a5fd3a --- /dev/null +++ b/database/factories/Hr/TaskFactory.php @@ -0,0 +1,19 @@ + */ +final class TaskFactory extends Factory +{ + protected $model = Task::class; + + public function definition(): array + { + return ['title' => fake()->sentence(6), 'project' => fake()->randomElement(['Website refresh', 'Launch plan', 'Customer research', 'Operations audit']), 'assignee' => fake()->name(), 'priority' => fake()->randomElement(['low', 'normal', 'high', 'urgent']), 'status' => fake()->randomElement(['todo', 'in-progress', 'blocked', 'done']), 'due_date' => fake()->optional()->dateTimeBetween('now', '+6 months'), 'estimate' => fake()->randomFloat(2, 1, 40)]; + } +} diff --git a/database/factories/Hr/TimesheetFactory.php b/database/factories/Hr/TimesheetFactory.php new file mode 100644 index 0000000..cfbaab1 --- /dev/null +++ b/database/factories/Hr/TimesheetFactory.php @@ -0,0 +1,19 @@ + */ +final class TimesheetFactory extends Factory +{ + protected $model = Timesheet::class; + + public function definition(): array + { + return ['employee' => fake()->name(), 'project' => fake()->randomElement(['Website refresh', 'Launch plan', 'Customer research', 'Operations audit']), 'work_date' => fake()->dateTimeBetween('-4 months', 'now'), 'hours' => fake()->randomFloat(2, 1, 10), 'status' => fake()->randomElement(['draft', 'submitted', 'approved']), 'notes' => fake()->optional()->sentence()]; + } +} diff --git a/database/factories/Shop/BrandFactory.php b/database/factories/Shop/BrandFactory.php new file mode 100644 index 0000000..7e078b5 --- /dev/null +++ b/database/factories/Shop/BrandFactory.php @@ -0,0 +1,22 @@ + */ +final class BrandFactory extends Factory +{ + protected $model = Brand::class; + + public function definition(): array + { + $name = fake()->unique()->company(); + + return ['name' => $name, 'slug' => Str::slug($name), 'status' => fake()->randomElement(['active', 'active', 'inactive']), 'website' => fake()->optional()->url(), 'sort' => 0]; + } +} diff --git a/database/factories/Shop/CustomerFactory.php b/database/factories/Shop/CustomerFactory.php new file mode 100644 index 0000000..06b6434 --- /dev/null +++ b/database/factories/Shop/CustomerFactory.php @@ -0,0 +1,25 @@ + */ +final class CustomerFactory extends Factory +{ + protected $model = Customer::class; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'phone' => fake()->optional()->phoneNumber(), + 'status' => fake()->randomElement(['active', 'active', 'inactive']), + 'notes' => fake()->optional()->sentence(), + ]; + } +} diff --git a/database/factories/Shop/OrderFactory.php b/database/factories/Shop/OrderFactory.php new file mode 100644 index 0000000..a70a393 --- /dev/null +++ b/database/factories/Shop/OrderFactory.php @@ -0,0 +1,31 @@ + */ +final class OrderFactory extends Factory +{ + protected $model = Order::class; + + public function definition(): array + { + return [ + 'number' => 'OR-'.fake()->unique()->numerify('######'), + 'customer_id' => Customer::factory(), + 'status' => fake()->randomElement(['paid', 'paid', 'pending', 'shipped', 'refunded', 'cancelled']), + 'payment_method' => fake()->randomElement(['card', 'bank_transfer', 'cash']), + 'total' => fake()->randomFloat(2, 48, 2400), + 'placed_at' => fake()->dateTimeBetween('-18 months', 'now'), + 'notes' => fake()->optional()->sentence(), + 'items' => [ + ['name' => fake()->words(2, true), 'quantity' => fake()->numberBetween(1, 4), 'price' => fake()->randomFloat(2, 20, 240)], + ], + ]; + } +} diff --git a/database/factories/Shop/ProductCategoryFactory.php b/database/factories/Shop/ProductCategoryFactory.php new file mode 100644 index 0000000..9df4dc6 --- /dev/null +++ b/database/factories/Shop/ProductCategoryFactory.php @@ -0,0 +1,22 @@ + */ +final class ProductCategoryFactory extends Factory +{ + protected $model = ProductCategory::class; + + public function definition(): array + { + $name = fake()->unique()->sentence(2); + + return ['name' => Str::title($name), 'slug' => Str::slug($name), 'description' => fake()->optional()->sentence()]; + } +} diff --git a/database/factories/Shop/ProductFactory.php b/database/factories/Shop/ProductFactory.php new file mode 100644 index 0000000..5efa145 --- /dev/null +++ b/database/factories/Shop/ProductFactory.php @@ -0,0 +1,30 @@ + */ +final class ProductFactory extends Factory +{ + protected $model = Product::class; + + public function definition(): array + { + $name = fake()->unique()->sentence(3); + + return [ + 'name' => Str::title($name), + 'sku' => strtoupper(fake()->unique()->bothify('SKU-####??')), + 'status' => fake()->randomElement(['active', 'active', 'draft', 'archived']), + 'price' => fake()->randomFloat(2, 18, 480), + 'stock' => fake()->numberBetween(0, 160), + 'description' => fake()->paragraph(), + 'featured' => fake()->boolean(18), + ]; + } +} diff --git a/database/migrations/2026_08_18_000000_create_showcase_tables.php b/database/migrations/2026_08_18_000000_create_showcase_tables.php new file mode 100644 index 0000000..02133ff --- /dev/null +++ b/database/migrations/2026_08_18_000000_create_showcase_tables.php @@ -0,0 +1,181 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('shop_brands', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('status', 20)->default('active')->index(); + $table->string('website')->nullable(); + $table->unsignedInteger('sort')->default(0)->index(); + $table->timestamps(); + }); + + Schema::create('shop_products', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('sku', 80)->unique(); + $table->string('status', 20)->default('active')->index(); + $table->decimal('price', 12, 2); + $table->unsignedInteger('stock')->default(0); + $table->text('description')->nullable(); + $table->boolean('featured')->default(false)->index(); + $table->timestamps(); + }); + + Schema::create('shop_customers', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('phone', 40)->nullable(); + $table->string('status', 20)->default('active')->index(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + + Schema::create('shop_orders', function (Blueprint $table): void { + $table->id(); + $table->string('number', 30)->unique(); + $table->foreignId('customer_id')->nullable()->constrained('shop_customers')->nullOnDelete(); + $table->string('status', 20)->default('pending')->index(); + $table->string('payment_method', 30)->default('card'); + $table->decimal('total', 12, 2); + $table->timestamp('placed_at')->index(); + $table->text('notes')->nullable(); + $table->json('items')->nullable(); + $table->timestamps(); + }); + + Schema::create('blog_authors', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->text('bio')->nullable(); + $table->boolean('active')->default(true)->index(); + $table->timestamps(); + }); + + Schema::create('blog_categories', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('hr_employees', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('department', 80)->index(); + $table->string('employment_type', 20)->default('full-time'); + $table->string('status', 20)->default('active')->index(); + $table->date('hire_date'); + $table->decimal('salary', 12, 2)->nullable(); + $table->json('skills')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + + Schema::create('hr_departments', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('parent')->nullable(); + $table->string('head')->nullable(); + $table->string('status', 20)->default('active')->index(); + $table->timestamps(); + }); + + Schema::create('hr_tasks', function (Blueprint $table): void { + $table->id(); + $table->string('title'); + $table->string('project')->nullable(); + $table->string('assignee')->nullable(); + $table->string('priority', 20)->default('normal')->index(); + $table->string('status', 20)->default('todo')->index(); + $table->date('due_date')->nullable(); + $table->decimal('estimate', 8, 2)->nullable(); + $table->timestamps(); + }); + + Schema::create('hr_timesheets', function (Blueprint $table): void { + $table->id(); + $table->string('employee'); + $table->string('project')->nullable(); + $table->date('work_date'); + $table->decimal('hours', 6, 2); + $table->string('status', 20)->default('draft')->index(); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + + Schema::create('hr_projects', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->string('status', 20)->default('planned')->index(); + $table->string('owner'); + $table->decimal('budget', 12, 2)->default(0); + $table->date('due_date')->nullable(); + $table->json('plan')->nullable(); + $table->timestamps(); + }); + + Schema::create('hr_leave_requests', function (Blueprint $table): void { + $table->id(); + $table->string('employee'); + $table->string('type', 30); + $table->string('status', 20)->default('pending')->index(); + $table->date('start_date'); + $table->date('end_date'); + $table->text('notes')->nullable(); + $table->timestamps(); + }); + + Schema::create('hr_expenses', function (Blueprint $table): void { + $table->id(); + $table->string('employee'); + $table->string('category', 40); + $table->string('status', 20)->default('submitted')->index(); + $table->decimal('amount', 12, 2); + $table->timestamp('submitted_at')->index(); + $table->timestamp('approved_at')->nullable(); + $table->json('line_items')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('hr_expenses'); + Schema::dropIfExists('hr_timesheets'); + Schema::dropIfExists('hr_tasks'); + Schema::dropIfExists('hr_departments'); + Schema::dropIfExists('hr_leave_requests'); + Schema::dropIfExists('hr_projects'); + Schema::dropIfExists('hr_employees'); + Schema::dropIfExists('blog_categories'); + Schema::dropIfExists('blog_authors'); + Schema::dropIfExists('shop_orders'); + Schema::dropIfExists('shop_customers'); + Schema::dropIfExists('shop_products'); + Schema::dropIfExists('shop_brands'); + Schema::dropIfExists('shop_product_categories'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 69063e7..f4ab528 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,6 +3,20 @@ namespace Database\Seeders; use App\Models\Blog; +use App\Models\Blog\Author; +use App\Models\Blog\Category; +use App\Models\Hr\Department; +use App\Models\Hr\Employee; +use App\Models\Hr\Expense; +use App\Models\Hr\LeaveRequest; +use App\Models\Hr\Project; +use App\Models\Hr\Task; +use App\Models\Hr\Timesheet; +use App\Models\Shop\Brand; +use App\Models\Shop\Customer; +use App\Models\Shop\Order; +use App\Models\Shop\Product; +use App\Models\Shop\ProductCategory; use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -62,26 +76,99 @@ public function run(): void Blog::query()->updateOrCreate(['slug' => $post['slug']], $post); } + // Shop, blog, and HR showcase data. Counts are intentionally varied + // and the guards keep `db:seed` safe to run more than once locally. + if (Product::query()->count() === 0) { + Product::factory(28)->create(); + } + + if (Brand::query()->count() === 0) { + Brand::factory(18)->sequence(fn ($sequence): array => ['sort' => $sequence->index + 1])->create(); + } + + if (ProductCategory::query()->count() === 0) { + ProductCategory::factory(14)->create(); + } + + if (Customer::query()->count() === 0) { + Customer::factory(90)->create(); + } + + if (Order::query()->count() === 0) { + $customers = Customer::query()->pluck('id'); + Order::factory(140)->state(fn (): array => [ + 'customer_id' => $customers->random(), + ])->create(); + } + + if (Author::query()->count() === 0) { + Author::factory(14)->create(); + } + + if (Category::query()->count() === 0) { + Category::factory(12)->create(); + } + + if (Employee::query()->count() === 0) { + Employee::factory(85)->create(); + } + + if (Department::query()->count() === 0) { + Department::factory(10)->create(); + } + + if (Project::query()->count() === 0) { + Project::factory(18)->create(); + } + + if (LeaveRequest::query()->count() === 0) { + LeaveRequest::factory(65)->create(); + } + + if (Expense::query()->count() === 0) { + Expense::factory(100)->create(); + } + + if (Task::query()->count() === 0) { + Task::factory(120)->create(); + } + + if (Timesheet::query()->count() === 0) { + Timesheet::factory(180)->create(); + } + $folder = MediaFolder::query()->updateOrCreate([ 'parent_id' => null, 'name' => 'Demo assets', ]); $disk = (string) config('media.disk', 'local'); - $path = trim((string) config('media.directory', 'media'), '/').'/welcome-to-inlay.txt'; - $contents = "Welcome to the Inlay media library.\n"; + $directory = trim((string) config('media.directory', 'media'), '/'); + $assets = [ + ['file_name' => 'welcome-to-inlay.txt', 'mime_type' => 'text/plain', 'extension' => 'txt', 'contents' => "Welcome to the Inlay media library.\n"], + ['file_name' => 'orders-overview.svg', 'mime_type' => 'image/svg+xml', 'extension' => 'svg', 'contents' => $this->demoAssetSvg('#047857', 'Orders')], + ['file_name' => 'people-workload.svg', 'mime_type' => 'image/svg+xml', 'extension' => 'svg', 'contents' => $this->demoAssetSvg('#4f46e5', 'People')], + ]; - Storage::disk($disk)->put($path, $contents); - MediaAsset::query()->updateOrCreate([ - 'disk' => $disk, - 'path' => $path, - ], [ - 'folder_id' => $folder->getKey(), - 'file_name' => 'welcome-to-inlay.txt', - 'mime_type' => 'text/plain', - 'extension' => 'txt', - 'size' => strlen($contents), - 'visibility' => 'private', - 'metadata' => ['description' => 'Seeded demo media asset'], - ]); + foreach ($assets as $asset) { + $path = $directory.'/'.$asset['file_name']; + Storage::disk($disk)->put($path, $asset['contents']); + MediaAsset::query()->updateOrCreate([ + 'disk' => $disk, + 'path' => $path, + ], [ + 'folder_id' => $folder->getKey(), + 'file_name' => $asset['file_name'], + 'mime_type' => $asset['mime_type'], + 'extension' => $asset['extension'], + 'size' => strlen($asset['contents']), + 'visibility' => 'private', + 'metadata' => ['description' => 'Seeded demo media asset'], + ]); + } + } + + private function demoAssetSvg(string $color, string $label): string + { + return ''.$label.'PHP-authored Inlay demo asset'; } } diff --git a/package.json b/package.json index 2551504..353d1c5 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@inlayphp/actions-react": "^0.3.10", "@inlayphp/core": "^0.3.10", "@inlayphp/forms-react": "^0.3.10", + "@inlayphp/infolists-react": "^0.3.10", "@inlayphp/media-manager-react": "^0.3.10", "@inlayphp/notifications": "^0.3.10", "@inlayphp/notifications-react": "^0.3.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53228a1..c49e29e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@inlayphp/forms-react': specifier: ^0.3.10 version: 0.3.10(@floating-ui/dom@1.8.0)(@inertiajs/react@3.6.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@inlayphp/actions-react@0.3.10(@inlayphp/actions@0.3.10(@inlayphp/core@0.3.10))(@inlayphp/core@0.3.10)(@inlayphp/ui-react@0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@inlayphp/actions@0.3.10(@inlayphp/core@0.3.10))(@inlayphp/core@0.3.10)(@inlayphp/theme@0.3.10)(@inlayphp/ui-react@0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tiptap/extensions@3.30.0(@tiptap/core@3.30.0(@tiptap/pm@3.30.0))(@tiptap/pm@3.30.0))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@inlayphp/infolists-react': + specifier: ^0.3.10 + version: 0.3.10(6n6qvx75qietys2n5berq3ceym) '@inlayphp/media-manager-react': specifier: ^0.3.10 version: 0.3.10(@inertiajs/react@3.6.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@inlayphp/theme@0.3.10)(@inlayphp/ui-react@0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -440,6 +443,19 @@ packages: react: ^19.0.0 react-dom: ^19.0.0 + '@inlayphp/infolists-react@0.3.10': + resolution: {integrity: sha512-boLd8lqgZiKastlj1ujgLrO10bG2mgC+127GnohMx9t0Z6pwTWqZ/eZaysxCAzqUyIDif39E68P5jvo0jc+HQA==} + engines: {node: '>=20'} + peerDependencies: + '@inertiajs/react': ^3.0.0 + '@inlayphp/actions': ^0.3.10 + '@inlayphp/actions-react': ^0.3.10 + '@inlayphp/core': ^0.3.10 + '@inlayphp/forms-react': ^0.3.10 + '@inlayphp/theme': ^0.3.10 + react: ^19.0.0 + react-dom: ^19.0.0 + '@inlayphp/media-manager-react@0.3.10': resolution: {integrity: sha512-L9MmmzNga0wRC5qbYuoSBOVEjgvSUROrfpEzEpsXgqoKt1SJa0PqZnrEamC9C2rDf86ubKm51Q1WjKQSrY2Erg==} engines: {node: '>=20'} @@ -3415,6 +3431,17 @@ snapshots: - '@types/react' - '@types/react-dom' + '@inlayphp/infolists-react@0.3.10(6n6qvx75qietys2n5berq3ceym)': + dependencies: + '@inertiajs/react': 3.6.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@inlayphp/actions': 0.3.10(@inlayphp/core@0.3.10) + '@inlayphp/actions-react': 0.3.10(@inlayphp/actions@0.3.10(@inlayphp/core@0.3.10))(@inlayphp/core@0.3.10)(@inlayphp/ui-react@0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@inlayphp/core': 0.3.10 + '@inlayphp/forms-react': 0.3.10(@floating-ui/dom@1.8.0)(@inertiajs/react@3.6.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@inlayphp/actions-react@0.3.10(@inlayphp/actions@0.3.10(@inlayphp/core@0.3.10))(@inlayphp/core@0.3.10)(@inlayphp/ui-react@0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@inlayphp/actions@0.3.10(@inlayphp/core@0.3.10))(@inlayphp/core@0.3.10)(@inlayphp/theme@0.3.10)(@inlayphp/ui-react@0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@tiptap/extensions@3.30.0(@tiptap/core@3.30.0(@tiptap/pm@3.30.0))(@tiptap/pm@3.30.0))(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@inlayphp/theme': 0.3.10 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@inlayphp/media-manager-react@0.3.10(@inertiajs/react@3.6.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@inlayphp/theme@0.3.10)(@inlayphp/ui-react@0.3.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@inertiajs/react': 3.6.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx index fa03f34..8108fc6 100644 --- a/resources/js/hooks/use-appearance.tsx +++ b/resources/js/hooks/use-appearance.tsx @@ -10,7 +10,7 @@ export type UseAppearanceReturn = { }; const listeners = new Set<() => void>(); -let currentAppearance: Appearance = 'system'; +let currentAppearance: Appearance = 'light'; const prefersDark = (): boolean => { if (typeof window === 'undefined') { @@ -31,10 +31,10 @@ const setCookie = (name: string, value: string, days = 365): void => { const getStoredAppearance = (): Appearance => { if (typeof window === 'undefined') { - return 'system'; + return 'light'; } - return (localStorage.getItem('appearance') as Appearance) || 'system'; + return (localStorage.getItem('appearance') as Appearance) || 'light'; }; const isDarkMode = (appearance: Appearance): boolean => { @@ -76,8 +76,8 @@ export function initializeTheme(): void { } if (!localStorage.getItem('appearance')) { - localStorage.setItem('appearance', 'system'); - setCookie('appearance', 'system'); + localStorage.setItem('appearance', 'light'); + setCookie('appearance', 'light'); } currentAppearance = getStoredAppearance(); diff --git a/resources/js/pages/inlay/auth/login.tsx b/resources/js/pages/inlay/auth/login.tsx index 6a7e104..f06cd2e 100644 --- a/resources/js/pages/inlay/auth/login.tsx +++ b/resources/js/pages/inlay/auth/login.tsx @@ -33,14 +33,43 @@ function value( function themeStyle(inlayPanel: PanelResource): CSSProperties { const light = inlayPanel.theme; const dark = { ...light, ...(inlayPanel.darkTheme ?? {}) }; + const recipes = recipeVariables({ + contract: 'inlay.themes.v1', + name: inlayPanel.themeName ?? 'default', + tokens: light, + darkTokens: inlayPanel.darkTheme ?? {}, + }); + const semanticTokens = new Set([ + 'accent', + 'accent-foreground', + 'background', + 'surface', + 'surface-muted', + 'foreground', + 'muted', + 'border', + 'control-border', + 'hover', + 'badge', + 'danger', + 'danger-surface', + 'success', + 'success-surface', + 'warning', + 'warning-surface', + 'info', + 'info-surface', + 'overlay', + 'scrim', + ]); + const recipeOnly = Object.fromEntries( + Object.entries(recipes).filter( + ([name]) => !semanticTokens.has(name.replace('--inlay-', '')), + ), + ); return { - ...recipeVariables({ - contract: 'inlay.themes.v1', - name: inlayPanel.themeName ?? 'default', - tokens: light, - darkTokens: inlayPanel.darkTheme ?? {}, - }), + ...recipeOnly, '--inlay-light-accent': value(light, 'accent', '#047857'), '--inlay-light-accent-foreground': value( light, diff --git a/resources/js/pages/inlay/dashboard.tsx b/resources/js/pages/inlay/dashboard.tsx index 1b41260..e1ddf58 100644 --- a/resources/js/pages/inlay/dashboard.tsx +++ b/resources/js/pages/inlay/dashboard.tsx @@ -1,143 +1,34 @@ -import { Head, Link, usePage } from '@inertiajs/react'; +import { Head, usePage } from '@inertiajs/react'; import { WidgetDashboard } from '@inlayphp/widgets-react'; import type { WidgetDashboardResource } from '@inlayphp/widgets-react'; -import { ArrowRight, Images, Newspaper, Settings2, Users } from 'lucide-react'; -import type { LucideIcon } from 'lucide-react'; import { inlayIcons } from '@/components/inlay-icons'; -import { InlayLogo } from '@/components/inlay-logo'; import InlayPanelLayout from '@/layouts/inlay-panel-layout'; -import type { User } from '@/types'; - -const destinations: Array<{ - description: string; - href: string; - icon: LucideIcon; - title: string; -}> = [ - { - title: 'Users', - description: - 'Create, search, edit, and remove panel user accounts through one PHP resource.', - href: '/admin/users', - icon: Users, - }, - { - title: 'Blog posts', - description: - 'Create, edit, publish, and search content with a complete resource.', - href: '/admin/blogs', - icon: Newspaper, - }, - { - title: 'Media library', - description: - 'Upload, organize, browse, and securely deliver application media.', - href: '/admin/media', - icon: Images, - }, - { - title: 'Account settings', - description: 'See the panel-native profile and password experience.', - href: '/admin/settings/account', - icon: Settings2, - }, -]; export default function Dashboard() { - const { auth, inlayWidgets } = usePage<{ - auth: { user: User }; + const { inlayPanel, inlayWidgets } = usePage<{ + inlayPanel: { + darkTheme?: Record; + id: string; + theme?: Record; + themeName?: string | null; + }; inlayWidgets: WidgetDashboardResource; }>().props; + const theme = { + contract: 'inlay.themes.v1' as const, + name: inlayPanel.themeName ?? inlayPanel.id, + tokens: inlayPanel.theme ?? {}, + darkTokens: inlayPanel.darkTheme ?? {}, + }; return ( - -
-
-
-

- Full feature demo -

-

- Welcome, {auth.user.name} -

-

- Manage users, blog posts, account settings, and - media from one Laravel panel. The widgets below are - resolved on the server. -

-
- - - Demo online - -
- - - -
-
-

- Manage the demo -

-

- Resources and account tools use the same panel - contracts. -

-
-
- {destinations.map( - ({ description, href, icon: Icon, title }) => ( - -
- - -
-
-

- {title} -

-

- {description} -

-
- - ), - )} -
-
- -
-
- -
-

- One PHP panel provider controls this experience -

-

- Brand, theme, navigation, authentication, - account settings, widgets, and resources are - registered from Laravel while React renders the - package contracts. -

-
-
-
-
+ +
); } diff --git a/resources/js/pages/inlay/resource/form.tsx b/resources/js/pages/inlay/resource/form.tsx new file mode 100644 index 0000000..8a6a033 --- /dev/null +++ b/resources/js/pages/inlay/resource/form.tsx @@ -0,0 +1,57 @@ +import { Head, usePage } from '@inertiajs/react'; +import { Form } from '@inlayphp/forms-react'; +import type { FormErrors, FormResource } from '@inlayphp/forms-react'; +import { ResourcePage } from '@inlayphp/resources-react'; +import type { + ResourceBreadcrumb, + ResourceSubNavigationItem, +} from '@inlayphp/resources-react'; +import InlayPanelLayout from '@/layouts/inlay-panel-layout'; + +type PageProps = { + breadcrumbs?: ResourceBreadcrumb[]; + errors: FormErrors; + form: FormResource; + heading: string; + inlayPanel: { + darkTheme?: Record; + id: string; + theme?: Record; + themeName?: string | null; + }; + subNavigation?: ResourceSubNavigationItem[]; + subheading?: string | null; +}; + +export default function ResourceForm({ + breadcrumbs = [], + form, + heading, + subheading, + subNavigation = [], +}: PageProps) { + const { errors, inlayPanel } = usePage().props; + const theme = { + contract: 'inlay.themes.v1' as const, + name: inlayPanel.themeName ?? inlayPanel.id, + tokens: inlayPanel.theme ?? {}, + darkTokens: inlayPanel.darkTheme ?? {}, + }; + + return ( + + + +
+
+
+
+
+ ); +} diff --git a/resources/js/pages/inlay/resource/index.tsx b/resources/js/pages/inlay/resource/index.tsx new file mode 100644 index 0000000..003741e --- /dev/null +++ b/resources/js/pages/inlay/resource/index.tsx @@ -0,0 +1,92 @@ +import { Head, Link, router, usePage } from '@inertiajs/react'; +import { ResourcePage } from '@inlayphp/resources-react'; +import type { + ResourceBreadcrumb, + ResourceSubNavigationItem, + ResourceTabsResource, +} from '@inlayphp/resources-react'; +import { Table } from '@inlayphp/tables-react'; +import type { TableResource } from '@inlayphp/tables-react'; +import { Plus } from 'lucide-react'; +import InlayPanelLayout from '@/layouts/inlay-panel-layout'; + +type PageRoute = { url: string | null; operation: string }; +type ResourceContract = { + label: string; + pages: Record; + pluralLabel: string; + slug: string; +}; +type PageProps = { + breadcrumbs?: ResourceBreadcrumb[]; + heading: string; + inlayPanel: { + darkTheme?: Record; + id: string; + theme?: Record; + themeName?: string | null; + }; + resource: ResourceContract; + subNavigation?: ResourceSubNavigationItem[]; + subheading?: string | null; + table: TableResource; + tabs?: ResourceTabsResource | null; +}; + +export default function ResourceIndex({ + breadcrumbs = [], + heading, + resource, + subheading, + subNavigation = [], + table, + tabs = null, +}: PageProps) { + const { inlayPanel } = usePage().props; + const createUrl = resource.pages.create?.url; + const theme = { + contract: 'inlay.themes.v1' as const, + name: inlayPanel.themeName ?? inlayPanel.id, + tokens: inlayPanel.theme ?? {}, + darkTokens: inlayPanel.darkTheme ?? {}, + }; + + return ( + + + +