├── .editorconfig ├── .env.example ├── .eslintrc.cjs ├── .gitattributes ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── LICENSE.md ├── Procfile ├── app ├── Http │ ├── Controllers │ │ ├── Auth │ │ │ └── AuthenticatedSessionController.php │ │ ├── ContactsController.php │ │ ├── Controller.php │ │ ├── DashboardController.php │ │ ├── ImagesController.php │ │ ├── OrganizationsController.php │ │ ├── ReportsController.php │ │ └── UsersController.php │ ├── Middleware │ │ ├── HandleInertiaRequests.php │ │ └── TrustProxies.php │ └── Requests │ │ └── Auth │ │ └── LoginRequest.php ├── Models │ ├── Account.php │ ├── Contact.php │ ├── Organization.php │ └── User.php └── Providers │ └── AppServiceProvider.php ├── artisan ├── bootstrap ├── app.php ├── cache │ └── .gitignore └── providers.php ├── composer.json ├── composer.lock ├── config ├── .gitkeep ├── database.php ├── inertia.php ├── mail.php ├── sanctum.php └── services.php ├── database ├── .gitignore ├── factories │ ├── ContactFactory.php │ ├── OrganizationFactory.php │ └── UserFactory.php ├── migrations │ ├── 2019_12_14_000001_create_personal_access_tokens_table.php │ ├── 2020_01_01_000001_create_password_resets_table.php │ ├── 2020_01_01_000002_create_failed_jobs_table.php │ ├── 2020_01_01_000003_create_accounts_table.php │ ├── 2020_01_01_000004_create_users_table.php │ ├── 2020_01_01_000005_create_organizations_table.php │ ├── 2020_01_01_000006_create_contacts_table.php │ ├── 2024_04_02_000000_add_expires_at_to_personal_access_tokens_table.php │ └── 2024_04_02_000000_rename_password_resets_table.php └── seeders │ └── DatabaseSeeder.php ├── package-lock.json ├── package.json ├── phpstan.neon ├── phpunit.xml ├── postcss.config.js ├── public ├── .htaccess ├── css │ └── app.css ├── favicon.svg ├── index.php ├── js │ └── app.js └── robots.txt ├── readme.md ├── resources ├── css │ ├── app.css │ ├── buttons.css │ └── form.css ├── js │ ├── Pages │ │ ├── Auth │ │ │ └── Login.svelte │ │ ├── Contacts │ │ │ ├── Create.svelte │ │ │ ├── Edit.svelte │ │ │ └── Index.svelte │ │ ├── Dashboard │ │ │ └── Index.svelte │ │ ├── Organizations │ │ │ ├── Create.svelte │ │ │ ├── Edit.svelte │ │ │ └── Index.svelte │ │ ├── Reports │ │ │ └── Index.svelte │ │ └── Users │ │ │ ├── Create.svelte │ │ │ ├── Edit.svelte │ │ │ └── Index.svelte │ ├── Shared │ │ ├── Dropdown.svelte │ │ ├── FileInput.svelte │ │ ├── FlashMessages.svelte │ │ ├── Icon.svelte │ │ ├── Label.svelte │ │ ├── Layout.svelte │ │ ├── LoadingButton.svelte │ │ ├── Logo.svelte │ │ ├── MainMenu.svelte │ │ ├── Pagination.svelte │ │ ├── SearchFilter.svelte │ │ ├── SelectInput.svelte │ │ ├── TextInput.svelte │ │ ├── TextareaInput.svelte │ │ └── TrashedMessage.svelte │ ├── app.js │ └── utils │ │ └── index.js └── views │ └── app.blade.php ├── routes ├── console.php └── web.php ├── screenshot.png ├── storage ├── app │ ├── .gitignore │ └── public │ │ └── .gitignore ├── debugbar │ └── .gitignore ├── framework │ ├── .gitignore │ ├── cache │ │ ├── .gitignore │ │ └── data │ │ │ └── .gitignore │ ├── sessions │ │ └── .gitignore │ ├── testing │ │ └── .gitignore │ └── views │ │ └── .gitignore └── logs │ └── .gitignore ├── tailwind.config.js ├── tests ├── Feature │ ├── ContactsTest.php │ └── OrganizationsTest.php ├── TestCase.php └── Unit │ └── ExampleTest.php └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{css,yml,yaml,svelte}] 15 | indent_size = 2 16 | 17 | [docker-compose.yml] 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=Laravel 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | APP_TIMEZONE=UTC 6 | APP_URL=http://localhost 7 | 8 | APP_LOCALE=en 9 | APP_FALLBACK_LOCALE=en 10 | APP_FAKER_LOCALE=en_US 11 | 12 | APP_MAINTENANCE_DRIVER=file 13 | APP_MAINTENANCE_STORE=database 14 | 15 | BCRYPT_ROUNDS=12 16 | 17 | LOG_CHANNEL=stack 18 | LOG_STACK=single 19 | LOG_DEPRECATIONS_CHANNEL=null 20 | LOG_LEVEL=debug 21 | 22 | DB_CONNECTION=sqlite 23 | #DB_HOST=127.0.0.1 24 | #DB_PORT=3306 25 | #DB_DATABASE=pingcrm 26 | #DB_USERNAME=root 27 | #DB_PASSWORD= 28 | 29 | BROADCAST_CONNECTION=log 30 | CACHE_STORE=file 31 | FILESYSTEM_DISK=local 32 | QUEUE_CONNECTION=sync 33 | SESSION_DRIVER=file 34 | SESSION_LIFETIME=120 35 | SESSION_ENCRYPT=false 36 | SESSION_PATH=/ 37 | SESSION_DOMAIN=null 38 | 39 | MEMCACHED_HOST=127.0.0.1 40 | 41 | REDIS_HOST=127.0.0.1 42 | REDIS_PASSWORD=null 43 | REDIS_PORT=6379 44 | 45 | MAIL_MAILER=smtp 46 | MAIL_HOST=mailhog 47 | MAIL_PORT=1025 48 | MAIL_USERNAME=null 49 | MAIL_PASSWORD=null 50 | MAIL_ENCRYPTION=null 51 | MAIL_FROM_ADDRESS=null 52 | MAIL_FROM_NAME="${APP_NAME}" 53 | 54 | AWS_ACCESS_KEY_ID= 55 | AWS_SECRET_ACCESS_KEY= 56 | AWS_DEFAULT_REGION=us-east-1 57 | AWS_BUCKET= 58 | AWS_USE_PATH_STYLE_ENDPOINT=false 59 | 60 | PUSHER_APP_ID= 61 | PUSHER_APP_KEY= 62 | PUSHER_APP_SECRET= 63 | PUSHER_APP_CLUSTER=mt1 64 | 65 | VITE_APP_NAME="${APP_NAME}" 66 | VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" 67 | VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" 68 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'plugin:svelte/recommended', 'plugin:svelte/prettier', 'prettier'], 3 | parserOptions: { 4 | sourceType: 'module', 5 | ecmaVersion: 2021, 6 | extraFileExtensions: ['.svelte'], 7 | }, 8 | env: { 9 | browser: true, 10 | amd: true, 11 | es6: true, 12 | }, 13 | rules: { 14 | indent: ['error', 2], 15 | quotes: ['warn', 'single'], 16 | semi: ['warn', 'never'], 17 | 'no-unused-vars': ['error', { vars: 'all', args: 'after-used', ignoreRestSiblings: true }], 18 | 'comma-dangle': ['warn', 'always-multiline'], 19 | 'svelte/no-at-html-tags': 'off', 20 | }, 21 | } 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.css linguist-vendored 3 | *.scss linguist-vendored 4 | *.js linguist-vendored 5 | CHANGELOG.md export-ignore 6 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [reinink] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bootstrap/ssr 2 | /node_modules 3 | /public/hot 4 | /public/build 5 | /public/storage 6 | /storage/*.key 7 | /vendor 8 | .DS_Store 9 | .env 10 | .env.backup 11 | .phpunit.result.cache 12 | .php-cs-fixer.php 13 | .php-cs-fixer.cache 14 | docker-compose.override.yml 15 | Homestead.json 16 | Homestead.yaml 17 | npm-debug.log 18 | /.idea 19 | /.vscode 20 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-svelte", 4 | "prettier-plugin-tailwindcss" 5 | ], 6 | "printWidth": 1000, 7 | "semi": false, 8 | "singleQuote": true, 9 | "tabWidth": 2, 10 | "trailingComma": "all", 11 | "htmlWhitespaceSensitivity": "css", 12 | "svelteBracketNewLine": false, 13 | "tailwindFunctions": ["clsx", "cva"] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Jonathan Reinink 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: vendor/bin/heroku-php-apache2 public/ 2 | -------------------------------------------------------------------------------- /app/Http/Controllers/Auth/AuthenticatedSessionController.php: -------------------------------------------------------------------------------- 1 | authenticate(); 30 | 31 | $request->session()->regenerate(); 32 | 33 | return redirect()->intended(AppServiceProvider::HOME); 34 | } 35 | 36 | /** 37 | * Destroy an authenticated session. 38 | */ 39 | public function destroy(Request $request): RedirectResponse 40 | { 41 | Auth::guard('web')->logout(); 42 | 43 | $request->session()->invalidate(); 44 | 45 | $request->session()->regenerateToken(); 46 | 47 | return redirect('/'); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/Http/Controllers/ContactsController.php: -------------------------------------------------------------------------------- 1 | Request::all('search', 'trashed'), 20 | 'contacts' => Auth::user()->account->contacts() 21 | ->with('organization') 22 | ->orderByName() 23 | ->filter(Request::only('search', 'trashed')) 24 | ->paginate(10) 25 | ->withQueryString() 26 | ->through(fn ($contact) => [ 27 | 'id' => $contact->id, 28 | 'name' => $contact->name, 29 | 'phone' => $contact->phone, 30 | 'city' => $contact->city, 31 | 'deleted_at' => $contact->deleted_at, 32 | 'organization' => $contact->organization ? $contact->organization->only('name') : null, 33 | ]), 34 | ]); 35 | } 36 | 37 | public function create(): Response 38 | { 39 | return Inertia::render('Contacts/Create', [ 40 | 'organizations' => Auth::user()->account 41 | ->organizations() 42 | ->orderBy('name') 43 | ->get() 44 | ->map 45 | ->only('id', 'name'), 46 | ]); 47 | } 48 | 49 | public function store(): RedirectResponse 50 | { 51 | Auth::user()->account->contacts()->create( 52 | Request::validate([ 53 | 'first_name' => ['required', 'max:50'], 54 | 'last_name' => ['required', 'max:50'], 55 | 'organization_id' => ['nullable', Rule::exists('organizations', 'id')->where(function ($query) { 56 | $query->where('account_id', Auth::user()->account_id); 57 | })], 58 | 'email' => ['nullable', 'max:50', 'email'], 59 | 'phone' => ['nullable', 'max:50'], 60 | 'address' => ['nullable', 'max:150'], 61 | 'city' => ['nullable', 'max:50'], 62 | 'region' => ['nullable', 'max:50'], 63 | 'country' => ['nullable', 'max:2'], 64 | 'postal_code' => ['nullable', 'max:25'], 65 | ]) 66 | ); 67 | 68 | return Redirect::route('contacts')->with('success', 'Contact created.'); 69 | } 70 | 71 | public function edit(Contact $contact): Response 72 | { 73 | return Inertia::render('Contacts/Edit', [ 74 | 'contact' => [ 75 | 'id' => $contact->id, 76 | 'first_name' => $contact->first_name, 77 | 'last_name' => $contact->last_name, 78 | 'organization_id' => $contact->organization_id, 79 | 'email' => $contact->email, 80 | 'phone' => $contact->phone, 81 | 'address' => $contact->address, 82 | 'city' => $contact->city, 83 | 'region' => $contact->region, 84 | 'country' => $contact->country, 85 | 'postal_code' => $contact->postal_code, 86 | 'deleted_at' => $contact->deleted_at, 87 | ], 88 | 'organizations' => Auth::user()->account->organizations() 89 | ->orderBy('name') 90 | ->get() 91 | ->map 92 | ->only('id', 'name'), 93 | ]); 94 | } 95 | 96 | public function update(Contact $contact): RedirectResponse 97 | { 98 | $contact->update( 99 | Request::validate([ 100 | 'first_name' => ['required', 'max:50'], 101 | 'last_name' => ['required', 'max:50'], 102 | 'organization_id' => [ 103 | 'nullable', 104 | Rule::exists('organizations', 'id')->where(fn ($query) => $query->where('account_id', Auth::user()->account_id)), 105 | ], 106 | 'email' => ['nullable', 'max:50', 'email'], 107 | 'phone' => ['nullable', 'max:50'], 108 | 'address' => ['nullable', 'max:150'], 109 | 'city' => ['nullable', 'max:50'], 110 | 'region' => ['nullable', 'max:50'], 111 | 'country' => ['nullable', 'max:2'], 112 | 'postal_code' => ['nullable', 'max:25'], 113 | ]) 114 | ); 115 | 116 | return Redirect::back()->with('success', 'Contact updated.'); 117 | } 118 | 119 | public function destroy(Contact $contact): RedirectResponse 120 | { 121 | $contact->delete(); 122 | 123 | return Redirect::back()->with('success', 'Contact deleted.'); 124 | } 125 | 126 | public function restore(Contact $contact): RedirectResponse 127 | { 128 | $contact->restore(); 129 | 130 | return Redirect::back()->with('success', 'Contact restored.'); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | new SymfonyResponseFactory($request), 16 | 'source' => $filesystem->getDriver(), 17 | 'cache' => $filesystem->getDriver(), 18 | 'cache_path_prefix' => '.glide-cache', 19 | ]); 20 | 21 | return $server->getImageResponse($path, $request->all()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/Http/Controllers/OrganizationsController.php: -------------------------------------------------------------------------------- 1 | Request::all('search', 'trashed'), 19 | 'organizations' => Auth::user()->account->organizations() 20 | ->orderBy('name') 21 | ->filter(Request::only('search', 'trashed')) 22 | ->paginate(10) 23 | ->withQueryString() 24 | ->through(fn ($organization) => [ 25 | 'id' => $organization->id, 26 | 'name' => $organization->name, 27 | 'phone' => $organization->phone, 28 | 'city' => $organization->city, 29 | 'deleted_at' => $organization->deleted_at, 30 | ]), 31 | ]); 32 | } 33 | 34 | public function create(): Response 35 | { 36 | return Inertia::render('Organizations/Create'); 37 | } 38 | 39 | public function store(): RedirectResponse 40 | { 41 | Auth::user()->account->organizations()->create( 42 | Request::validate([ 43 | 'name' => ['required', 'max:100'], 44 | 'email' => ['nullable', 'max:50', 'email'], 45 | 'phone' => ['nullable', 'max:50'], 46 | 'address' => ['nullable', 'max:150'], 47 | 'city' => ['nullable', 'max:50'], 48 | 'region' => ['nullable', 'max:50'], 49 | 'country' => ['nullable', 'max:2'], 50 | 'postal_code' => ['nullable', 'max:25'], 51 | ]) 52 | ); 53 | 54 | return Redirect::route('organizations')->with('success', 'Organization created.'); 55 | } 56 | 57 | public function edit(Organization $organization): Response 58 | { 59 | return Inertia::render('Organizations/Edit', [ 60 | 'organization' => [ 61 | 'id' => $organization->id, 62 | 'name' => $organization->name, 63 | 'email' => $organization->email, 64 | 'phone' => $organization->phone, 65 | 'address' => $organization->address, 66 | 'city' => $organization->city, 67 | 'region' => $organization->region, 68 | 'country' => $organization->country, 69 | 'postal_code' => $organization->postal_code, 70 | 'deleted_at' => $organization->deleted_at, 71 | 'contacts' => $organization->contacts()->orderByName()->get()->map->only('id', 'name', 'city', 'phone'), 72 | ], 73 | ]); 74 | } 75 | 76 | public function update(Organization $organization): RedirectResponse 77 | { 78 | $organization->update( 79 | Request::validate([ 80 | 'name' => ['required', 'max:100'], 81 | 'email' => ['nullable', 'max:50', 'email'], 82 | 'phone' => ['nullable', 'max:50'], 83 | 'address' => ['nullable', 'max:150'], 84 | 'city' => ['nullable', 'max:50'], 85 | 'region' => ['nullable', 'max:50'], 86 | 'country' => ['nullable', 'max:2'], 87 | 'postal_code' => ['nullable', 'max:25'], 88 | ]) 89 | ); 90 | 91 | return Redirect::back()->with('success', 'Organization updated.'); 92 | } 93 | 94 | public function destroy(Organization $organization): RedirectResponse 95 | { 96 | $organization->delete(); 97 | 98 | return Redirect::back()->with('success', 'Organization deleted.'); 99 | } 100 | 101 | public function restore(Organization $organization): RedirectResponse 102 | { 103 | $organization->restore(); 104 | 105 | return Redirect::back()->with('success', 'Organization restored.'); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/Http/Controllers/ReportsController.php: -------------------------------------------------------------------------------- 1 | Request::all('search', 'role', 'trashed'), 22 | 'users' => Auth::user()->account->users() 23 | ->orderByName() 24 | ->filter(Request::only('search', 'role', 'trashed')) 25 | ->get() 26 | ->transform(fn ($user) => [ 27 | 'id' => $user->id, 28 | 'name' => $user->name, 29 | 'email' => $user->email, 30 | 'owner' => $user->owner, 31 | 'photo' => $user->photo_path ? URL::route('image', ['path' => $user->photo_path, 'w' => 40, 'h' => 40, 'fit' => 'crop']) : null, 32 | 'deleted_at' => $user->deleted_at, 33 | ]), 34 | ]); 35 | } 36 | 37 | public function create(): Response 38 | { 39 | return Inertia::render('Users/Create'); 40 | } 41 | 42 | public function store(): RedirectResponse 43 | { 44 | Request::validate([ 45 | 'first_name' => ['required', 'max:50'], 46 | 'last_name' => ['required', 'max:50'], 47 | 'email' => ['required', 'max:50', 'email', Rule::unique('users')], 48 | 'password' => ['nullable'], 49 | 'owner' => ['required', 'boolean'], 50 | 'photo' => ['nullable', 'image'], 51 | ]); 52 | 53 | Auth::user()->account->users()->create([ 54 | 'first_name' => Request::get('first_name'), 55 | 'last_name' => Request::get('last_name'), 56 | 'email' => Request::get('email'), 57 | 'password' => Request::get('password'), 58 | 'owner' => Request::get('owner'), 59 | 'photo_path' => Request::file('photo') ? Request::file('photo')->store('users') : null, 60 | ]); 61 | 62 | return Redirect::route('users')->with('success', 'User created.'); 63 | } 64 | 65 | public function edit(User $user): Response 66 | { 67 | return Inertia::render('Users/Edit', [ 68 | 'user' => [ 69 | 'id' => $user->id, 70 | 'first_name' => $user->first_name, 71 | 'last_name' => $user->last_name, 72 | 'email' => $user->email, 73 | 'owner' => $user->owner, 74 | 'photo' => $user->photo_path ? URL::route('image', ['path' => $user->photo_path, 'w' => 60, 'h' => 60, 'fit' => 'crop']) : null, 75 | 'deleted_at' => $user->deleted_at, 76 | ], 77 | ]); 78 | } 79 | 80 | public function update(User $user): RedirectResponse 81 | { 82 | if (App::environment('demo') && $user->isDemoUser()) { 83 | return Redirect::back()->with('error', 'Updating the demo user is not allowed.'); 84 | } 85 | 86 | Request::validate([ 87 | 'first_name' => ['required', 'max:50'], 88 | 'last_name' => ['required', 'max:50'], 89 | 'email' => ['required', 'max:50', 'email', Rule::unique('users')->ignore($user->id)], 90 | 'password' => ['nullable'], 91 | 'owner' => ['required', 'boolean'], 92 | 'photo' => ['nullable', 'image'], 93 | ]); 94 | 95 | $user->update(Request::only('first_name', 'last_name', 'email', 'owner')); 96 | 97 | if (Request::file('photo')) { 98 | $user->update(['photo_path' => Request::file('photo')->store('users')]); 99 | } 100 | 101 | if (Request::get('password')) { 102 | $user->update(['password' => Request::get('password')]); 103 | } 104 | 105 | return Redirect::back()->with('success', 'User updated.'); 106 | } 107 | 108 | public function destroy(User $user): RedirectResponse 109 | { 110 | if (App::environment('demo') && $user->isDemoUser()) { 111 | return Redirect::back()->with('error', 'Deleting the demo user is not allowed.'); 112 | } 113 | 114 | $user->delete(); 115 | 116 | return Redirect::back()->with('success', 'User deleted.'); 117 | } 118 | 119 | public function restore(User $user): RedirectResponse 120 | { 121 | $user->restore(); 122 | 123 | return Redirect::back()->with('success', 'User restored.'); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /app/Http/Middleware/HandleInertiaRequests.php: -------------------------------------------------------------------------------- 1 | function () use ($request) { 38 | return [ 39 | 'user' => $request->user() ? [ 40 | 'id' => $request->user()->id, 41 | 'first_name' => $request->user()->first_name, 42 | 'last_name' => $request->user()->last_name, 43 | 'email' => $request->user()->email, 44 | 'owner' => $request->user()->owner, 45 | 'account' => [ 46 | 'id' => $request->user()->account->id, 47 | 'name' => $request->user()->account->name, 48 | ], 49 | ] : null, 50 | ]; 51 | }, 52 | 'flash' => function () use ($request) { 53 | return [ 54 | 'success' => $request->session()->get('success'), 55 | 'error' => $request->session()->get('error'), 56 | ]; 57 | }, 58 | ]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/Http/Middleware/TrustProxies.php: -------------------------------------------------------------------------------- 1 | |string|null 14 | */ 15 | protected $proxies = '*'; 16 | 17 | /** 18 | * The headers that should be used to detect proxies. 19 | * 20 | * @var int 21 | */ 22 | protected $headers = 23 | Request::HEADER_X_FORWARDED_FOR | 24 | Request::HEADER_X_FORWARDED_HOST | 25 | Request::HEADER_X_FORWARDED_PORT | 26 | Request::HEADER_X_FORWARDED_PROTO | 27 | Request::HEADER_X_FORWARDED_AWS_ELB; 28 | } 29 | -------------------------------------------------------------------------------- /app/Http/Requests/Auth/LoginRequest.php: -------------------------------------------------------------------------------- 1 | 'required|string|email', 33 | 'password' => 'required|string', 34 | ]; 35 | } 36 | 37 | /** 38 | * Attempt to authenticate the request's credentials. 39 | * 40 | * @return void 41 | * 42 | * @throws \Illuminate\Validation\ValidationException 43 | */ 44 | public function authenticate() 45 | { 46 | $this->ensureIsNotRateLimited(); 47 | 48 | if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { 49 | RateLimiter::hit($this->throttleKey()); 50 | 51 | throw ValidationException::withMessages([ 52 | 'email' => __('auth.failed'), 53 | ]); 54 | } 55 | 56 | RateLimiter::clear($this->throttleKey()); 57 | } 58 | 59 | /** 60 | * Ensure the login request is not rate limited. 61 | * 62 | * @return void 63 | * 64 | * @throws \Illuminate\Validation\ValidationException 65 | */ 66 | public function ensureIsNotRateLimited() 67 | { 68 | if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) { 69 | return; 70 | } 71 | 72 | event(new Lockout($this)); 73 | 74 | $seconds = RateLimiter::availableIn($this->throttleKey()); 75 | 76 | throw ValidationException::withMessages([ 77 | 'email' => trans('auth.throttle', [ 78 | 'seconds' => $seconds, 79 | 'minutes' => ceil($seconds / 60), 80 | ]), 81 | ]); 82 | } 83 | 84 | /** 85 | * Get the rate limiting throttle key for the request. 86 | * 87 | * @return string 88 | */ 89 | public function throttleKey() 90 | { 91 | return Str::lower($this->input('email')).'|'.$this->ip(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/Models/Account.php: -------------------------------------------------------------------------------- 1 | hasMany(User::class); 13 | } 14 | 15 | public function organizations(): HasMany 16 | { 17 | return $this->hasMany(Organization::class); 18 | } 19 | 20 | public function contacts(): HasMany 21 | { 22 | return $this->hasMany(Contact::class); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /app/Models/Contact.php: -------------------------------------------------------------------------------- 1 | where($field ?? 'id', $value)->withTrashed()->firstOrFail(); 18 | } 19 | 20 | public function organization(): BelongsTo 21 | { 22 | return $this->belongsTo(Organization::class); 23 | } 24 | 25 | public function getNameAttribute() 26 | { 27 | return $this->first_name.' '.$this->last_name; 28 | } 29 | 30 | public function scopeOrderByName($query) 31 | { 32 | $query->orderBy('last_name')->orderBy('first_name'); 33 | } 34 | 35 | public function scopeFilter($query, array $filters) 36 | { 37 | $query->when($filters['search'] ?? null, function ($query, $search) { 38 | $query->where(function ($query) use ($search) { 39 | $query->where('first_name', 'like', '%'.$search.'%') 40 | ->orWhere('last_name', 'like', '%'.$search.'%') 41 | ->orWhere('email', 'like', '%'.$search.'%') 42 | ->orWhereHas('organization', function ($query) use ($search) { 43 | $query->where('name', 'like', '%'.$search.'%'); 44 | }); 45 | }); 46 | })->when($filters['trashed'] ?? null, function ($query, $trashed) { 47 | if ($trashed === 'with') { 48 | $query->withTrashed(); 49 | } elseif ($trashed === 'only') { 50 | $query->onlyTrashed(); 51 | } 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/Models/Organization.php: -------------------------------------------------------------------------------- 1 | where($field ?? 'id', $value)->withTrashed()->firstOrFail(); 18 | } 19 | 20 | public function contacts(): HasMany 21 | { 22 | return $this->hasMany(Contact::class); 23 | } 24 | 25 | public function scopeFilter($query, array $filters) 26 | { 27 | $query->when($filters['search'] ?? null, function ($query, $search) { 28 | $query->where('name', 'like', '%'.$search.'%'); 29 | })->when($filters['trashed'] ?? null, function ($query, $trashed) { 30 | if ($trashed === 'with') { 31 | $query->withTrashed(); 32 | } elseif ($trashed === 'only') { 33 | $query->onlyTrashed(); 34 | } 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Models/User.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | protected $fillable = [ 23 | 'name', 24 | 'email', 25 | 'password', 26 | ]; 27 | 28 | /** 29 | * The attributes that should be hidden for serialization. 30 | * 31 | * @var array 32 | */ 33 | protected $hidden = [ 34 | 'password', 35 | 'remember_token', 36 | ]; 37 | 38 | /** 39 | * Get the attributes that should be cast. 40 | * 41 | * @return array 42 | */ 43 | protected function casts(): array 44 | { 45 | return [ 46 | 'owner' => 'boolean', 47 | 'email_verified_at' => 'datetime', 48 | ]; 49 | } 50 | 51 | public function resolveRouteBinding($value, $field = null) 52 | { 53 | return $this->where($field ?? 'id', $value)->withTrashed()->firstOrFail(); 54 | } 55 | 56 | public function account(): BelongsTo 57 | { 58 | return $this->belongsTo(Account::class); 59 | } 60 | 61 | public function getNameAttribute() 62 | { 63 | return $this->first_name.' '.$this->last_name; 64 | } 65 | 66 | public function setPasswordAttribute($password) 67 | { 68 | $this->attributes['password'] = Hash::needsRehash($password) ? Hash::make($password) : $password; 69 | } 70 | 71 | public function isDemoUser() 72 | { 73 | return $this->email === 'johndoe@example.com'; 74 | } 75 | 76 | public function scopeOrderByName($query) 77 | { 78 | $query->orderBy('last_name')->orderBy('first_name'); 79 | } 80 | 81 | public function scopeWhereRole($query, $role) 82 | { 83 | switch ($role) { 84 | case 'user': return $query->where('owner', false); 85 | case 'owner': return $query->where('owner', true); 86 | } 87 | } 88 | 89 | public function scopeFilter($query, array $filters) 90 | { 91 | $query->when($filters['search'] ?? null, function ($query, $search) { 92 | $query->where(function ($query) use ($search) { 93 | $query->where('first_name', 'like', '%'.$search.'%') 94 | ->orWhere('last_name', 'like', '%'.$search.'%') 95 | ->orWhere('email', 'like', '%'.$search.'%'); 96 | }); 97 | })->when($filters['role'] ?? null, function ($query, $role) { 98 | $query->whereRole($role); 99 | })->when($filters['trashed'] ?? null, function ($query, $trashed) { 100 | if ($trashed === 'with') { 101 | $query->withTrashed(); 102 | } elseif ($trashed === 'only') { 103 | $query->onlyTrashed(); 104 | } 105 | }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /app/Providers/AppServiceProvider.php: -------------------------------------------------------------------------------- 1 | bootRoute(); 38 | } 39 | 40 | public function bootRoute(): void 41 | { 42 | RateLimiter::for('api', function (Request $request) { 43 | return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); 44 | }); 45 | 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | handleCommand(new ArgvInput); 14 | 15 | exit($status); 16 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withProviders() 10 | ->withRouting( 11 | web: __DIR__.'/../routes/web.php', 12 | // api: __DIR__.'/../routes/api.php', 13 | commands: __DIR__.'/../routes/console.php', 14 | // channels: __DIR__.'/../routes/channels.php', 15 | health: '/up', 16 | ) 17 | ->withMiddleware(function (Middleware $middleware) { 18 | $middleware->redirectGuestsTo(fn () => route('login')); 19 | $middleware->redirectUsersTo(AppServiceProvider::HOME); 20 | 21 | $middleware->web(\App\Http\Middleware\HandleInertiaRequests::class); 22 | 23 | $middleware->throttleApi(); 24 | 25 | $middleware->replace(\Illuminate\Http\Middleware\TrustProxies::class, \App\Http\Middleware\TrustProxies::class); 26 | }) 27 | ->withExceptions(function (Exceptions $exceptions) { 28 | // 29 | })->create(); 30 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'table' => 'migrations', 7 | 'update_date_on_publish' => false, // disable to preserve original behavior for existing applications 8 | ], 9 | 10 | ]; 11 | -------------------------------------------------------------------------------- /config/inertia.php: -------------------------------------------------------------------------------- 1 | [ 20 | 21 | 'enabled' => true, 22 | 23 | 'url' => 'http://127.0.0.1:13714/render', 24 | 25 | ], 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | Testing 30 | |-------------------------------------------------------------------------- 31 | | 32 | | The values described here are used to locate Inertia components on the 33 | | filesystem. For instance, when using `assertInertia`, the assertion 34 | | attempts to locate the component as a file relative to any of the 35 | | paths AND with any of the extensions specified here. 36 | | 37 | */ 38 | 39 | 'testing' => [ 40 | 41 | 'ensure_pages_exist' => true, 42 | 43 | 'page_paths' => [ 44 | 45 | resource_path('js/Pages'), 46 | 47 | ], 48 | 49 | 'page_extensions' => [ 50 | 51 | 'js', 52 | 'jsx', 53 | 'svelte', 54 | 'ts', 55 | 'tsx', 56 | 'vue', 57 | 58 | ], 59 | 60 | ], 61 | 62 | ]; 63 | -------------------------------------------------------------------------------- /config/mail.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'mailgun' => [ 7 | 'transport' => 'mailgun', 8 | // 'client' => [ 9 | // 'timeout' => 5, 10 | // ], 11 | ], 12 | 13 | 'roundrobin' => [ 14 | 'transport' => 'roundrobin', 15 | 'mailers' => [ 16 | 'ses', 17 | 'postmark', 18 | ], 19 | ], 20 | ], 21 | 22 | ]; 23 | -------------------------------------------------------------------------------- /config/sanctum.php: -------------------------------------------------------------------------------- 1 | explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( 19 | '%s%s', 20 | 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', 21 | Sanctum::currentApplicationUrlWithPort() 22 | ))), 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Sanctum Guards 27 | |-------------------------------------------------------------------------- 28 | | 29 | | This array contains the authentication guards that will be checked when 30 | | Sanctum is trying to authenticate a request. If none of these guards 31 | | are able to authenticate the request, Sanctum will use the bearer 32 | | token that's present on an incoming request for authentication. 33 | | 34 | */ 35 | 36 | 'guard' => ['web'], 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Expiration Minutes 41 | |-------------------------------------------------------------------------- 42 | | 43 | | This value controls the number of minutes until an issued token will be 44 | | considered expired. This will override any values set in the token's 45 | | "expires_at" attribute, but first-party sessions are not affected. 46 | | 47 | */ 48 | 49 | 'expiration' => null, 50 | 51 | /* 52 | |-------------------------------------------------------------------------- 53 | | Token Prefix 54 | |-------------------------------------------------------------------------- 55 | | 56 | | Sanctum can prefix new tokens in order to take advantage of numerous 57 | | security scanning initiatives maintained by open source platforms 58 | | that notify developers if they commit tokens into repositories. 59 | | 60 | | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning 61 | | 62 | */ 63 | 64 | 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), 65 | 66 | /* 67 | |-------------------------------------------------------------------------- 68 | | Sanctum Middleware 69 | |-------------------------------------------------------------------------- 70 | | 71 | | When authenticating your first-party SPA with Sanctum you may need to 72 | | customize some of the middleware Sanctum uses while processing the 73 | | request. You may change the middleware listed below as required. 74 | | 75 | */ 76 | 77 | 'middleware' => [ 78 | 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, 79 | 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, 80 | 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, 81 | ], 82 | 83 | ]; 84 | -------------------------------------------------------------------------------- /config/services.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'domain' => env('MAILGUN_DOMAIN'), 7 | 'secret' => env('MAILGUN_SECRET'), 8 | 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), 9 | 'scheme' => 'https', 10 | ], 11 | 12 | ]; 13 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite 2 | -------------------------------------------------------------------------------- /database/factories/ContactFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->firstName(), 16 | 'last_name' => $this->faker->lastName(), 17 | 'email' => $this->faker->unique()->safeEmail(), 18 | 'phone' => $this->faker->tollFreePhoneNumber(), 19 | 'address' => $this->faker->streetAddress(), 20 | 'city' => $this->faker->city(), 21 | 'region' => $this->faker->state(), 22 | 'country' => 'US', 23 | 'postal_code' => $this->faker->postcode(), 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /database/factories/OrganizationFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->company(), 16 | 'email' => $this->faker->companyEmail(), 17 | 'phone' => $this->faker->tollFreePhoneNumber(), 18 | 'address' => $this->faker->streetAddress(), 19 | 'city' => $this->faker->city(), 20 | 'region' => $this->faker->state(), 21 | 'country' => 'US', 22 | 'postal_code' => $this->faker->postcode(), 23 | ]; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | $this->faker->firstName(), 22 | 'last_name' => $this->faker->lastName(), 23 | 'email' => $this->faker->unique()->safeEmail(), 24 | 'email_verified_at' => now(), 25 | 'password' => static::$password ??= Hash::make('password'), 26 | 'remember_token' => Str::random(10), 27 | 'owner' => false, 28 | ]; 29 | } 30 | 31 | /** 32 | * Indicate that the model's email address should be unverified. 33 | */ 34 | public function unverified(): Factory 35 | { 36 | return $this->state(function (array $attributes) { 37 | return [ 38 | 'email_verified_at' => null, 39 | ]; 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->morphs('tokenable'); 17 | $table->string('name'); 18 | $table->string('token', 64)->unique(); 19 | $table->text('abilities')->nullable(); 20 | $table->timestamp('last_used_at')->nullable(); 21 | $table->timestamps(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('personal_access_tokens'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000001_create_password_resets_table.php: -------------------------------------------------------------------------------- 1 | string('email')->index(); 16 | $table->string('token'); 17 | $table->timestamp('created_at')->nullable(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | */ 24 | public function down(): void 25 | { 26 | Schema::dropIfExists('password_resets'); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000002_create_failed_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 16 | $table->string('uuid')->unique(); 17 | $table->text('connection'); 18 | $table->text('queue'); 19 | $table->longText('payload'); 20 | $table->longText('exception'); 21 | $table->timestamp('failed_at')->useCurrent(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the migrations. 27 | */ 28 | public function down(): void 29 | { 30 | Schema::dropIfExists('failed_jobs'); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000003_create_accounts_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->string('name', 50); 17 | $table->timestamps(); 18 | }); 19 | } 20 | 21 | /** 22 | * Reverse the migrations. 23 | */ 24 | public function down(): void 25 | { 26 | Schema::dropIfExists('accounts'); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000004_create_users_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->integer('account_id')->index(); 17 | $table->string('first_name', 25); 18 | $table->string('last_name', 25); 19 | $table->string('email', 50)->unique(); 20 | $table->timestamp('email_verified_at')->nullable(); 21 | $table->string('password')->nullable(); 22 | $table->boolean('owner')->default(false); 23 | $table->string('photo_path', 100)->nullable(); 24 | $table->rememberToken(); 25 | $table->timestamps(); 26 | $table->softDeletes(); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | */ 33 | public function down(): void 34 | { 35 | Schema::dropIfExists('users'); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000005_create_organizations_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->integer('account_id')->index(); 17 | $table->string('name', 100); 18 | $table->string('email', 50)->nullable(); 19 | $table->string('phone', 50)->nullable(); 20 | $table->string('address', 150)->nullable(); 21 | $table->string('city', 50)->nullable(); 22 | $table->string('region', 50)->nullable(); 23 | $table->string('country', 2)->nullable(); 24 | $table->string('postal_code', 25)->nullable(); 25 | $table->timestamps(); 26 | $table->softDeletes(); 27 | }); 28 | } 29 | 30 | /** 31 | * Reverse the migrations. 32 | */ 33 | public function down(): void 34 | { 35 | Schema::dropIfExists('organizations'); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /database/migrations/2020_01_01_000006_create_contacts_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->integer('account_id')->index(); 17 | $table->integer('organization_id')->nullable()->index(); 18 | $table->string('first_name', 25); 19 | $table->string('last_name', 25); 20 | $table->string('email', 50)->nullable(); 21 | $table->string('phone', 50)->nullable(); 22 | $table->string('address', 150)->nullable(); 23 | $table->string('city', 50)->nullable(); 24 | $table->string('region', 50)->nullable(); 25 | $table->string('country', 2)->nullable(); 26 | $table->string('postal_code', 25)->nullable(); 27 | $table->timestamps(); 28 | $table->softDeletes(); 29 | }); 30 | } 31 | 32 | /** 33 | * Reverse the migrations. 34 | */ 35 | public function down(): void 36 | { 37 | Schema::dropIfExists('contacts'); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /database/migrations/2024_04_02_000000_add_expires_at_to_personal_access_tokens_table.php: -------------------------------------------------------------------------------- 1 | timestamp('expires_at')->nullable()->after('last_used_at'); 16 | }); 17 | } 18 | 19 | /** 20 | * Reverse the migrations. 21 | */ 22 | public function down(): void 23 | { 24 | Schema::table('personal_access_tokens', function (Blueprint $table) { 25 | $table->dropColumn('expires_at'); 26 | }); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /database/migrations/2024_04_02_000000_rename_password_resets_table.php: -------------------------------------------------------------------------------- 1 | 'Acme Corporation']); 19 | 20 | User::factory()->create([ 21 | 'account_id' => $account->id, 22 | 'first_name' => 'John', 23 | 'last_name' => 'Doe', 24 | 'email' => 'johndoe@example.com', 25 | 'password' => 'secret', 26 | 'owner' => true, 27 | ]); 28 | 29 | User::factory(5)->create(['account_id' => $account->id]); 30 | 31 | $organizations = Organization::factory(100) 32 | ->create(['account_id' => $account->id]); 33 | 34 | Contact::factory(100) 35 | ->create(['account_id' => $account->id]) 36 | ->each(function ($contact) use ($organizations) { 37 | $contact->update(['organization_id' => $organizations->random()->id]); 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "type": "module", 4 | "scripts": { 5 | "fix:eslint": "eslint --ext .js,.svelte resources/js/ --fix", 6 | "fix:prettier": "prettier --write --loglevel warn 'resources/js/**/*.{js,svelte}'", 7 | "fix-code-style": "npm run fix:prettier && npm run fix:eslint", 8 | "heroku-postbuild": "npm run build", 9 | "dev": "vite", 10 | "build": "vite build && vite build --ssr" 11 | }, 12 | "dependencies": { 13 | "@inertiajs/svelte": "^1.2.0", 14 | "@popperjs/core": "^2.11.0", 15 | "fit-textarea": "^2.0.0", 16 | "lodash": "^4.17.21", 17 | "nanoid": "^5.0.7" 18 | }, 19 | "devDependencies": { 20 | "@sveltejs/vite-plugin-svelte": "^3.1.1", 21 | "autoprefixer": "^10.4.19", 22 | "eslint": "^8.56.0", 23 | "eslint-config-prettier": "^9.1.0", 24 | "eslint-plugin-svelte": "^2.43.0", 25 | "laravel-vite-plugin": "^1.0.5", 26 | "postcss": "^8.4.38", 27 | "postcss-import": "^16.1.0", 28 | "prettier": "^3.3.3", 29 | "prettier-plugin-svelte": "^3.2.6", 30 | "prettier-plugin-tailwindcss": "^0.6.5", 31 | "svelte": "^4.2.18", 32 | "tailwindcss": "^3.4.7", 33 | "vite": "^5.4.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/larastan/larastan/extension.neon 3 | 4 | parameters: 5 | 6 | paths: 7 | - app/ 8 | 9 | # Level 9 is the highest level 10 | level: 1 11 | 12 | # ignoreErrors: 13 | # - '#PHPDoc tag @var#' 14 | # 15 | # excludePaths: 16 | # - ./*/*/FileToBeExcluded.php 17 | # 18 | # checkMissingIterableValueType: false 19 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests/Unit 10 | 11 | 12 | tests/Feature 13 | 14 | 15 | 16 | 17 | app 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'tailwindcss/nesting': {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handleRequest(Request::capture()); 18 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Ping CRM - Svelte 2 | 3 | A demo application to illustrate how Inertia.js works. 4 | 5 | ![](https://raw.githubusercontent.com/inertiajs/pingcrm/master/screenshot.png) 6 | 7 | ## Installation 8 | 9 | Clone the repo locally: 10 | 11 | ```sh 12 | git clone https://github.com/inertiajs/pingcrm-svelte.git pingcrm-svelte 13 | cd pingcrm-svelte 14 | ``` 15 | 16 | Install PHP dependencies: 17 | 18 | ```sh 19 | composer install 20 | ``` 21 | 22 | Install NPM dependencies: 23 | 24 | ```sh 25 | npm ci 26 | ``` 27 | 28 | Build assets: 29 | 30 | ```sh 31 | npm run dev 32 | ``` 33 | 34 | Setup configuration: 35 | 36 | ```sh 37 | cp .env.example .env 38 | ``` 39 | 40 | Generate application key: 41 | 42 | ```sh 43 | php artisan key:generate 44 | ``` 45 | 46 | Create an SQLite database. You can also use another database (MySQL, Postgres), simply update your configuration accordingly. 47 | 48 | ```sh 49 | touch database/database.sqlite 50 | ``` 51 | 52 | Run database migrations: 53 | 54 | ```sh 55 | php artisan migrate 56 | ``` 57 | 58 | Run database seeder: 59 | 60 | ```sh 61 | php artisan db:seed 62 | ``` 63 | 64 | Run the dev server (the output will give the address): 65 | 66 | ```sh 67 | php artisan serve 68 | ``` 69 | 70 | You're ready to go! Visit Ping CRM in your browser, and login with: 71 | 72 | - **Username:** johndoe@example.com 73 | - **Password:** secret 74 | 75 | ## Running tests 76 | 77 | To run the Ping CRM tests, run: 78 | 79 | ``` 80 | phpunit 81 | ``` 82 | -------------------------------------------------------------------------------- /resources/css/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | 3 | @import 'tailwindcss/components'; 4 | @import 'buttons'; 5 | @import 'form'; 6 | 7 | @import 'tailwindcss/utilities'; 8 | -------------------------------------------------------------------------------- /resources/css/buttons.css: -------------------------------------------------------------------------------- 1 | .btn-indigo { 2 | @apply px-6 py-3 rounded bg-indigo-600 text-white text-sm leading-4 font-bold whitespace-nowrap hover:bg-orange-400 focus:bg-orange-400; 3 | } 4 | 5 | .btn-spinner, 6 | .btn-spinner:after { 7 | border-radius: 50%; 8 | width: 1.5em; 9 | height: 1.5em; 10 | } 11 | 12 | .btn-spinner { 13 | font-size: 10px; 14 | position: relative; 15 | text-indent: -9999em; 16 | border-top: 0.2em solid white; 17 | border-right: 0.2em solid white; 18 | border-bottom: 0.2em solid white; 19 | border-left: 0.2em solid transparent; 20 | transform: translateZ(0); 21 | animation: spinning 1s infinite linear; 22 | } 23 | 24 | @keyframes spinning { 25 | 0% { 26 | transform: rotate(0deg); 27 | } 28 | 100% { 29 | transform: rotate(360deg); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /resources/css/form.css: -------------------------------------------------------------------------------- 1 | .form-label { 2 | @apply mb-2 block text-gray-700 select-none; 3 | } 4 | 5 | .form-input, 6 | .form-textarea, 7 | .form-select { 8 | @apply p-2 leading-normal block w-full border text-gray-700 bg-white font-sans rounded text-left appearance-none relative focus:border-indigo-400 focus:ring; 9 | 10 | &::placeholder { 11 | @apply text-gray-500 opacity-100; 12 | } 13 | } 14 | 15 | .form-select { 16 | @apply pr-6; 17 | 18 | background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAQCAYAAAAMJL+VAAAABGdBTUEAALGPC/xhBQAAAQtJREFUOBG1lEEOgjAQRalbGj2OG9caOACn4ALGtfEuHACiazceR1PWOH/CNA3aMiTaBDpt/7zPdBKy7M/DCL9pGkvxxVp7KsvyJftL5rZt1865M+Ucq6pyyF3hNcI7Cuu+728QYn/JQA5yKaempxuZmQngOwEaYx55nu+1lQh8GIatMGi+01NwBcEmhxBqK4nAPZJ78K0KKFAJmR3oPp8+Iwgob0Oa6+TLoeCvRx+mTUYf/FVBGTPRwDkfLxnaSrRwcH0FWhNOmrkWYbE2XEicqgSa1J0LQ+aPCuQgZiLnwewbGuz5MGoAhcIkCQcjaTBjMgtXGURMVHC1wcQEy0J+Zlj8bKAnY1/UzDe2dbAVqfXn6wAAAABJRU5ErkJggg=='); 19 | background-size: 0.7rem; 20 | background-repeat: no-repeat; 21 | background-position: right 0.7rem center; 22 | 23 | &::-ms-expand { 24 | @apply opacity-0; 25 | } 26 | } 27 | 28 | .form-input.error, 29 | .form-textarea.error, 30 | .form-select.error { 31 | @apply border-red-500 focus:ring focus:ring-red-200; 32 | } 33 | 34 | .form-error { 35 | @apply text-red-700 mt-2 text-sm; 36 | } 37 | -------------------------------------------------------------------------------- /resources/js/Pages/Auth/Login.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | Login - Ping CRM 20 | 21 | 22 |
23 |
24 | 25 |
26 |
27 |

Welcome Back!

28 |
29 | 30 | 31 | 35 |
36 |
37 | Forgot password? 38 | Login 39 |
40 | 41 |
42 |
43 | -------------------------------------------------------------------------------- /resources/js/Pages/Contacts/Create.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 33 | 34 |

35 | Contacts 36 | / Create 37 |

38 |
39 |
40 |
41 | 42 | 43 | 44 | 47 | {/each} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 57 | 58 | 59 | 60 |
61 |
62 | Create Contact 63 |
64 |
65 |
66 | -------------------------------------------------------------------------------- /resources/js/Pages/Contacts/Edit.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 48 | 49 |

50 | Contacts 51 | / 52 | {contact.first_name} 53 | {contact.last_name} 54 |

55 | {#if contact.deleted_at} 56 | This contact has been deleted. 57 | {/if} 58 |
59 |
60 |
61 | 62 | 63 | 64 | 69 | {/each} 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 79 | 80 | 81 | 82 |
83 |
84 | {#if !contact.deleted_at} 85 | 86 | {/if} 87 | Update Contact 88 |
89 |
90 |
91 | -------------------------------------------------------------------------------- /resources/js/Pages/Contacts/Index.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 38 | 39 |

Contacts

40 |
41 | 42 | 43 | 48 | 49 | 50 | Create 51 | 52 | 53 |
54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | {#each contacts.data as contact (contact.id)} 63 | 64 | 72 | 77 | 82 | 87 | 92 | 93 | {/each} 94 | 95 | {#if contacts.data.length === 0} 96 | 97 | 98 | 99 | {/if} 100 |
NameOrganizationCityPhone
65 | 66 | {contact.name} 67 | {#if contact.deleted_at} 68 | 69 | {/if} 70 | 71 | 73 | 74 | {#if contact.organization}{contact.organization.name}{/if} 75 | 76 | 78 | 79 | {contact.city || ''} 80 | 81 | 83 | 84 | {contact.phone || ''} 85 | 86 | 88 | 89 | 90 | 91 |
No contacts found.
101 |
102 | 103 | -------------------------------------------------------------------------------- /resources/js/Pages/Dashboard/Index.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 10 |

Dashboard

11 |

12 | Hey there! Welcome to Ping CRM, a demo app designed to help illustrate how Inertia.js works. 13 |

14 | -------------------------------------------------------------------------------- /resources/js/Pages/Organizations/Create.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 29 | 30 |

31 | Organizations 32 | / Create 33 |

34 | 35 |
36 |
37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 48 | 49 | 50 |
51 |
52 | Create Organization 53 |
54 |
55 |
56 | -------------------------------------------------------------------------------- /resources/js/Pages/Organizations/Edit.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 46 | 47 |

48 | Organizations 49 | / 50 | {organization.name} 51 |

52 | 53 | {#if organization.deleted_at} 54 | This organization has been deleted. 55 | {/if} 56 | 57 |
58 |
59 |
60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 69 | 70 | 71 | 72 |
73 |
74 | {#if !organization.deleted_at} 75 | 76 | {/if} 77 | 78 | Update Organization 79 |
80 |
81 |
82 | 83 |

Contacts

84 |
85 | 86 | 87 | 88 | 89 | 90 | 91 | {#each organization.contacts as contact (contact.id)} 92 | 93 | 101 | 106 | 111 | 116 | 117 | {/each} 118 | 119 | {#if organization.contacts.length === 0} 120 | 121 | 122 | 123 | {/if} 124 |
NameCityPhone
94 | 95 | {contact.name} 96 | {#if contact.deleted_at} 97 | 98 | {/if} 99 | 100 | 102 | 103 | {contact.city} 104 | 105 | 107 | 108 | {contact.phone} 109 | 110 | 112 | 113 | 114 | 115 |
No contacts found.
125 |
126 | -------------------------------------------------------------------------------- /resources/js/Pages/Organizations/Index.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 38 | 39 |

Organizations

40 |
41 | 42 | 43 | 48 | 49 | 50 | Create 51 | 52 | 53 |
54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | {#each organizations.data as organization (organization.id)} 62 | 63 | 71 | 76 | 81 | 86 | 87 | {/each} 88 | 89 | {#if organizations.data.length === 0} 90 | 91 | 92 | 93 | {/if} 94 |
NameCityPhone
64 | 65 | {organization.name} 66 | {#if organization.deleted_at} 67 | 68 | {/if} 69 | 70 | 72 | 73 | {organization.city || ''} 74 | 75 | 77 | 78 | {organization.phone || ''} 79 | 80 | 82 | 83 | 84 | 85 |
No organizations found.
95 |
96 | 97 | -------------------------------------------------------------------------------- /resources/js/Pages/Reports/Index.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 9 | 10 |
11 |

Reports

12 |
13 | -------------------------------------------------------------------------------- /resources/js/Pages/Users/Create.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 28 | 29 |

30 | Users 31 | / Create 32 |

33 | 34 |
35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 | Create User 49 |
50 |
51 |
52 | -------------------------------------------------------------------------------- /resources/js/Pages/Users/Edit.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 47 | 48 |
49 |

50 | Users 51 | / 52 | {user.first_name} 53 | {user.last_name} 54 |

55 | 56 | {#if user.photo} 57 | {`${user.first_name} 58 | {/if} 59 |
60 | 61 | {#if user.deleted_at} 62 | This user has been deleted. 63 | {/if} 64 | 65 |
66 |
67 |
68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 |
78 |
79 | {#if !user.deleted_at} 80 | 81 | {/if} 82 | 83 | Update User 84 |
85 |
86 |
87 | -------------------------------------------------------------------------------- /resources/js/Pages/Users/Index.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 38 | 39 |

Users

40 |
41 | 42 | 43 | 48 | 49 | 54 | 55 | 56 | Create 57 | 58 | 59 |
60 |
61 | 62 | 63 | 64 | 65 | 66 | 67 | {#each users as user (user.id)} 68 | 69 | 80 | 85 | 90 | 95 | 96 | {/each} 97 | 98 | {#if users.length === 0} 99 | 100 | 101 | 102 | {/if} 103 |
NameEmailRole
70 | 71 | {#if user.photo} 72 | user 73 | {/if} 74 | {user.name} 75 | {#if user.deleted_at} 76 | 77 | {/if} 78 | 79 | 81 | 82 | {user.email || ''} 83 | 84 | 86 | 87 | {user.owner ? 'Owner' : 'User'} 88 | 89 | 91 | 92 | 93 | 94 |
No users found.
104 |
105 | -------------------------------------------------------------------------------- /resources/js/Shared/Dropdown.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 | 51 | 52 | 55 | 56 | {#if show} 57 |
58 | 59 |
(show = false)} 63 | /> 64 | 65 |
(show = !autoclose)}> 66 | 67 |
68 |
69 | {/if} 70 | -------------------------------------------------------------------------------- /resources/js/Shared/FileInput.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 |
36 |
60 | -------------------------------------------------------------------------------- /resources/js/Shared/FlashMessages.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {#if $page.props.flash.success && show} 12 |
13 |
14 | 15 | 16 | 17 |
{$page.props.flash.success}
18 |
19 | 30 |
31 | {/if} 32 | 33 | {#if $page.props.flash.error || (Object.keys($page.props.errors).length > 0 && show)} 34 |
35 |
36 | 37 | 42 | 43 | {#if $page.props.flash.error} 44 |
{$page.props.flash.error}
45 | {:else} 46 |
47 | {#if Object.keys($page.props.errors).length === 1} 48 | There is one form error. 49 | {:else} 50 | There are {Object.keys($page.props.errors).length} form errors. 51 | {/if} 52 |
53 | {/if} 54 |
55 | 66 |
67 | {/if} 68 | -------------------------------------------------------------------------------- /resources/js/Shared/Icon.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | {#if name === 'cheveron-down'} 6 | 7 | {:else if name === 'cheveron-right'} 8 | 9 | {:else if name === 'dashboard'} 10 | 11 | {:else if name === 'office'} 12 | 13 | {:else if name === 'printer'} 14 | 15 | {:else if name === 'trash'} 16 | 17 | {:else if name === 'users'} 18 | 19 | {/if} 20 | -------------------------------------------------------------------------------- /resources/js/Shared/Label.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {#if label} 12 | 13 | {/if} 14 | -------------------------------------------------------------------------------- /resources/js/Shared/Layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 16 | 17 | 18 | {$title ? `${$title} - Ping CRM` : 'Ping CRM'} 19 | 20 | 21 |
22 |
23 |
24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 | 34 |
35 |
36 |
37 |
38 |
{auth.user.account.name}
39 | 40 |
41 |
42 | {auth.user.first_name} 43 | 44 |
45 | 46 |
47 |
48 | My Profile 49 | Manage Users 50 | 51 |
52 |
53 |
54 |
55 |
56 |
62 |
63 |
64 | -------------------------------------------------------------------------------- /resources/js/Shared/LoadingButton.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 17 | -------------------------------------------------------------------------------- /resources/js/Shared/Logo.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /resources/js/Shared/MainMenu.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 42 | -------------------------------------------------------------------------------- /resources/js/Shared/Pagination.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {#if links.length > 3} 8 |
9 | {#each links as link, key (key)} 10 | {#if link.url === null} 11 |
12 | {@html link.label} 13 |
14 | {:else} 15 | 16 | {@html link.label} 17 | 18 | {/if} 19 | {/each} 20 |
21 | {/if} 22 | -------------------------------------------------------------------------------- /resources/js/Shared/SearchFilter.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 |
18 | 19 |
20 | 21 | 22 | 26 | 27 |
28 |
29 | 30 |
31 |
32 | 33 | 34 |
35 | 36 | 37 |
38 | -------------------------------------------------------------------------------- /resources/js/Shared/SelectInput.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |
29 |
39 | -------------------------------------------------------------------------------- /resources/js/Shared/TextInput.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 |
27 |
35 | -------------------------------------------------------------------------------- /resources/js/Shared/TextareaInput.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 |
35 |