diff --git a/check_user.php b/check_user.php
new file mode 100644
index 0000000..d638272
--- /dev/null
+++ b/check_user.php
@@ -0,0 +1 @@
+make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); foreach (App\Models\User::with('roles')->get() as \) { echo \->name . ' | ' . \->email . ' | Roles: ' . \->roles->pluck('name')->implode(',') . PHP_EOL; }
\ No newline at end of file
diff --git a/config/database.php b/config/database.php
index 64709ce..49f3483 100644
--- a/config/database.php
+++ b/config/database.php
@@ -38,7 +38,7 @@ return [
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
- 'busy_timeout' => null,
+ 'busy_timeout' => 5000,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 987f7d0..b2169c2 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -2,38 +2,30 @@
namespace Database\Seeders;
-use App\Models\User;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
-
/**
- * Seed the application's database.
+ * Seed the application's core data.
*/
public function run(): void
{
$this->call([
+ // 1. Roles & Permissions setup
ConstructionRolesAndPermissionsSeeder::class,
SuperAdminPermissionSeeder::class,
- SuperAdminSeeder::class,
- MainContractorSeeder::class, // Must run after SuperAdminSeeder
\Modules\RolesPermissions\Database\Seeders\RolesPermissionsDatabaseSeeder::class,
+
+ // 2. Super Admin Account & Main Contractor Firm
+ SuperAdminSeeder::class,
+ MainContractorSeeder::class,
+
+ // 3. Master Data Catalogs (Standard Items & Rates)
\Modules\Labors\Database\Seeders\LaborsDatabaseSeeder::class,
\Modules\Equipments\Database\Seeders\EquipmentsDatabaseSeeder::class,
+ \Modules\MaterialLogistics\Database\Seeders\ConstructionMaterialsSeeder::class,
]);
-
- $admin = User::firstOrCreate(
- ['email' => 'admin@example.com'],
- [
- 'name' => 'Super Administrator',
- 'password' => bcrypt('password'),
- 'user_type' => 'admin',
- 'status' => 'active',
- 'contractor_id' => null,
- ]
- );
-
- $admin->assignRole('Super Admin');
}
}
+
diff --git a/package-lock.json b/package-lock.json
index ab978fb..1b2319c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,10 +1,9 @@
{
- "name": "GSB-Construction",
+ "name": "gsb",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "GSB-Construction",
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
@@ -13,6 +12,7 @@
"clsx": "^2.1.1",
"lucide-react": "^1.0.1",
"next-themes": "^0.4.6",
+ "react-grab": "^0.1.50",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
@@ -67,6 +67,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -1237,6 +1238,12 @@
"hono": "^4"
}
},
+ "node_modules/@iarna/toml": {
+ "version": "2.2.5",
+ "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz",
+ "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==",
+ "license": "ISC"
+ },
"node_modules/@inertiajs/core": {
"version": "2.3.18",
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-2.3.18.tgz",
@@ -1472,6 +1479,7 @@
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -1634,6 +1642,150 @@
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
+ "node_modules/@react-grab/cli": {
+ "version": "0.1.50",
+ "resolved": "https://registry.npmjs.org/@react-grab/cli/-/cli-0.1.50.tgz",
+ "integrity": "sha512-Px/Hwhhyk2PubCA4ZaRFsfvwxhbxXsetJyvqC6aFFi8WhJhA+oVC33aTzuAeWmM3fhb4/8ce8YsHXI1d6ChcKg==",
+ "dependencies": {
+ "agent-install": "^0.0.6",
+ "commander": "^14.0.3",
+ "ignore": "^7.0.5",
+ "ora": "^9.4.0",
+ "package-manager-detector": "^1.6.0",
+ "picocolors": "^1.1.1",
+ "prompts": "^2.4.2",
+ "tinyexec": "^1.1.2"
+ },
+ "bin": {
+ "react-grab": "bin/cli.js"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/cli-spinners": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz",
+ "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/ignore": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+ "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/log-symbols": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
+ "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
+ "license": "MIT",
+ "dependencies": {
+ "is-unicode-supported": "^2.0.0",
+ "yoctocolors": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/ora": {
+ "version": "9.4.1",
+ "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz",
+ "integrity": "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==",
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^5.6.2",
+ "cli-cursor": "^5.0.0",
+ "cli-spinners": "^3.2.0",
+ "is-interactive": "^2.0.0",
+ "is-unicode-supported": "^2.1.0",
+ "log-symbols": "^7.0.1",
+ "stdin-discarder": "^0.3.2",
+ "string-width": "^8.1.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/stdin-discarder": {
+ "version": "0.3.2",
+ "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
+ "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/string-width": {
+ "version": "8.2.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
+ "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
+ "license": "MIT",
+ "dependencies": {
+ "get-east-asian-width": "^1.5.0",
+ "strip-ansi": "^7.1.2"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@react-grab/cli/node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
"node_modules/@react-stately/flags": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz",
@@ -2452,6 +2604,7 @@
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -2469,6 +2622,7 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2570,6 +2724,23 @@
"node": ">= 14"
}
},
+ "node_modules/agent-install": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/agent-install/-/agent-install-0.0.6.tgz",
+ "integrity": "sha512-7NRMZ/ZDz2vHevQTgJsocBFpakB1/Wx5ip19YSJuj4VOXpraWztTerViNtdSyARKZT9e2yVwUUB5JXXCE7mNrA==",
+ "license": "MIT",
+ "dependencies": {
+ "@iarna/toml": "^2.2.5",
+ "commander": "^14.0.0",
+ "jsonc-parser": "^3.3.1",
+ "picocolors": "^1.1.1",
+ "prompts": "^2.4.2",
+ "yaml": "^2.8.3"
+ },
+ "bin": {
+ "agent-install": "bin/agent-install.mjs"
+ }
+ },
"node_modules/ajv": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@@ -2750,6 +2921,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/bippy": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/bippy/-/bippy-0.6.1.tgz",
+ "integrity": "sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=17.0.1"
+ }
+ },
"node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -2830,6 +3010,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4194,6 +4375,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz",
"integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -4592,6 +4774,12 @@
"node": ">=6"
}
},
+ "node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "license": "MIT"
+ },
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@@ -5504,6 +5692,12 @@
"integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
"license": "MIT"
},
+ "node_modules/package-manager-detector": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
+ "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
+ "license": "MIT"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5622,6 +5816,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
@@ -5794,6 +5989,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -5806,6 +6002,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5814,6 +6011,27 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-grab": {
+ "version": "0.1.50",
+ "resolved": "https://registry.npmjs.org/react-grab/-/react-grab-0.1.50.tgz",
+ "integrity": "sha512-zRkHKq/8a1msCpEOp8BDROeQZT50m0OH2XPrP6jk5op+JAHrlsm3pj7eAQMOsct87EZDeGNnu4r+sGsJJzyw1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@react-grab/cli": "0.1.50",
+ "bippy": "^0.6.1"
+ },
+ "bin": {
+ "react-grab": "bin/cli.js"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
@@ -5867,7 +6085,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/require-directory": {
"version": "2.1.1",
@@ -6487,7 +6706,8 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -6509,6 +6729,15 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6703,6 +6932,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6826,6 +7056,7 @@
"integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -6997,6 +7228,21 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
@@ -7053,6 +7299,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 20a5c01..adda4cf 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"clsx": "^2.1.1",
"lucide-react": "^1.0.1",
"next-themes": "^0.4.6",
+ "react-grab": "^0.1.50",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
diff --git a/public/flags/gb.svg b/public/flags/gb.svg
new file mode 100644
index 0000000..e69de29
diff --git a/resources/js/Components/ConfirmModal.tsx b/resources/js/Components/ConfirmModal.tsx
new file mode 100644
index 0000000..70c1977
--- /dev/null
+++ b/resources/js/Components/ConfirmModal.tsx
@@ -0,0 +1,115 @@
+import React from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/Components/ui/dialog';
+import { Button } from '@/Components/ui/button';
+import { AlertTriangle, Info, CheckCircle2, Send, HelpCircle } from 'lucide-react';
+
+interface ConfirmModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ title?: string;
+ message?: string;
+ confirmText?: string;
+ cancelText?: string;
+ variant?: 'default' | 'destructive' | 'warning' | 'info';
+ onConfirm: () => void;
+ onCancel?: () => void;
+}
+
+export function ConfirmModal({
+ open,
+ onOpenChange,
+ title = 'Are you sure?',
+ message = 'Please confirm if you wish to proceed with this action.',
+ confirmText = 'Confirm',
+ cancelText = 'Cancel',
+ variant = 'info',
+ onConfirm,
+ onCancel,
+}: ConfirmModalProps) {
+ const renderIcon = () => {
+ switch (variant) {
+ case 'destructive':
+ return
;
+ case 'warning':
+ return
;
+ case 'info':
+ return
;
+ default:
+ return
;
+ }
+ };
+
+ const confirmButtonClass = () => {
+ switch (variant) {
+ case 'destructive':
+ return 'bg-rose-600 hover:bg-rose-700 text-white font-medium shadow-sm';
+ case 'warning':
+ return 'bg-amber-600 hover:bg-amber-700 text-white font-medium shadow-sm';
+ case 'info':
+ return 'bg-slate-900 hover:bg-slate-800 text-white font-medium shadow-sm';
+ default:
+ return 'bg-emerald-600 hover:bg-emerald-700 text-white font-medium shadow-sm';
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/resources/js/Components/ui/dialog.tsx b/resources/js/Components/ui/dialog.tsx
index d2ea080..d410535 100644
--- a/resources/js/Components/ui/dialog.tsx
+++ b/resources/js/Components/ui/dialog.tsx
@@ -100,7 +100,7 @@ function DialogFooter({
- {/* Contractor Context Banner (Option D) */}
+ {/* Contractor Context Banner */}
{isContractorUser && contractorName && (
-
-
-
- You are managing {contractorName}.
- All users you create will be added to this company.
-
+
+
+
+
+ Current Active Contractor: {contractorName}
+ {auth.user.roles?.some(r => r.name === 'Super Admin') ? (
+ (Platform Super Admin)
+ ) : (
+ — Users you create will be automatically attached to this company.
+ )}
+
+
)}
diff --git a/resources/js/app.tsx b/resources/js/app.tsx
index c6d84a5..aabfc9e 100644
--- a/resources/js/app.tsx
+++ b/resources/js/app.tsx
@@ -7,6 +7,10 @@ import { createRoot } from 'react-dom/client';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
+if (import.meta.env.DEV) {
+ import('react-grab');
+}
+
// Glob all page components from both app and modules
const appPages = import.meta.glob('./Pages/**/*.tsx');
const modulePages = import.meta.glob('../../Modules/*/resources/js/Pages/**/*.tsx');
diff --git a/tests/Feature/ProjectWizardFlowTest.php b/tests/Feature/ProjectWizardFlowTest.php
new file mode 100644
index 0000000..03768b6
--- /dev/null
+++ b/tests/Feature/ProjectWizardFlowTest.php
@@ -0,0 +1,595 @@
+ 'Super Admin', 'guard_name' => 'web']);
+
+ $this->admin = User::factory()->create([
+ 'user_type' => 'admin',
+ 'status' => 'active',
+ ]);
+ $this->admin->assignRole('Super Admin');
+
+ $permission = \Spatie\Permission\Models\Permission::firstOrCreate([
+ 'name' => 'projects.access',
+ 'guard_name' => 'web',
+ ]);
+ $this->admin->givePermissionTo($permission);
+ }
+
+ // ─── STEP 1: Project Create/Store ────────────────────────────────────────
+
+ /** @test */
+ public function guest_cannot_access_project_create(): void
+ {
+ $this->get(route('projects.create'))->assertRedirect(route('login'));
+ }
+
+ /** @test */
+ public function admin_can_view_project_create_page(): void
+ {
+ $this->actingAs($this->admin)
+ ->get(route('projects.create'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->component('ProjectManagement::Projects/Create', false));
+
+ }
+
+ /** @test */
+ public function project_store_fails_without_name(): void
+ {
+ $this->actingAs($this->admin)
+ ->post(route('projects.store'), ['name' => '', 'project_type' => 'standard'])
+ ->assertSessionHasErrors('name');
+ }
+
+ /** @test */
+ public function project_store_fails_without_project_type(): void
+ {
+ $this->actingAs($this->admin)
+ ->post(route('projects.store'), ['name' => 'Test', 'project_type' => ''])
+ ->assertSessionHasErrors('project_type');
+ }
+
+ /** @test */
+ public function project_store_fails_with_invalid_project_type(): void
+ {
+ $this->actingAs($this->admin)
+ ->post(route('projects.store'), ['name' => 'Test', 'project_type' => 'invalid'])
+ ->assertSessionHasErrors('project_type');
+ }
+
+ /** @test */
+ public function project_store_fails_when_end_date_before_start_date(): void
+ {
+ $this->actingAs($this->admin)
+ ->post(route('projects.store'), [
+ 'name' => 'Date Test',
+ 'project_type' => 'standard',
+ 'start_date' => '2025-12-31',
+ 'target_end_date' => '2025-01-01',
+ ])
+ ->assertSessionHasErrors('target_end_date');
+ }
+
+ /** @test */
+ public function project_store_succeeds_with_minimum_fields(): void
+ {
+ $this->actingAs($this->admin)
+ ->post(route('projects.store'), [
+ 'name' => 'Minimal Project',
+ 'project_type' => 'standard',
+ ]);
+
+ $project = Project::where('name', 'Minimal Project')->first();
+ $this->assertNotNull($project);
+ $this->assertEquals(2, $project->current_wizard_step);
+ }
+
+ /** @test */
+ public function project_store_auto_generates_code(): void
+ {
+ $this->actingAs($this->admin)
+ ->post(route('projects.store'), [
+ 'name' => 'Code Project',
+ 'project_type' => 'standard',
+ ]);
+
+ $project = Project::where('name', 'Code Project')->first();
+ $this->assertStringStartsWith('PRJ-' . now()->year . '-', $project->code);
+ }
+
+ /** @test */
+ public function project_codes_are_unique_and_sequential(): void
+ {
+ $this->actingAs($this->admin)->post(route('projects.store'), ['name' => 'P1', 'project_type' => 'standard']);
+ $this->actingAs($this->admin)->post(route('projects.store'), ['name' => 'P2', 'project_type' => 'standard']);
+
+ $c1 = Project::where('name', 'P1')->value('code');
+ $c2 = Project::where('name', 'P2')->value('code');
+
+ $this->assertNotEquals($c1, $c2);
+ $this->assertStringStartsWith('PRJ-', $c1);
+ }
+
+ /** @test */
+ public function project_store_saves_all_optional_fields(): void
+ {
+ $this->actingAs($this->admin)->post(route('projects.store'), [
+ 'name' => 'Full Project',
+ 'client_name' => 'Acme Corp',
+ 'description' => 'Desc',
+ 'location' => 'Manila',
+ 'contract_value' => 5000000,
+ 'contract_duration' => 365,
+ 'start_date' => '2025-01-01',
+ 'target_end_date' => '2025-12-31',
+ 'project_type' => 'standard',
+ 'is_unprofitable' => false,
+ ]);
+
+ $project = Project::where('name', 'Full Project')->first();
+ $this->assertEquals('Acme Corp', $project->client_name);
+ $this->assertEquals(5000000, $project->contract_value);
+ }
+
+ // ─── STEP 2: Milestones & Tasks ──────────────────────────────────────────
+
+ /** @test */
+ public function save_tasks_fails_with_empty_milestone_name(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 2]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.tasks', $project), [
+ 'milestones' => [['name' => '', 'weight_percentage' => 50]],
+ ])
+ ->assertSessionHasErrors('milestones.0.name');
+ }
+
+ /** @test */
+ public function save_tasks_fails_with_milestone_weight_over_100(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 2]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.tasks', $project), [
+ 'milestones' => [['name' => 'Phase 1', 'weight_percentage' => 150]],
+ ])
+ ->assertSessionHasErrors('milestones.0.weight_percentage');
+ }
+
+ /** @test */
+ public function save_tasks_fails_with_empty_task_name(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 2]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.tasks', $project), [
+ 'tasks' => [['name' => '']],
+ ])
+ ->assertSessionHasErrors('tasks.0.name');
+ }
+
+ /** @test */
+ public function save_tasks_with_empty_arrays_advances_step(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 2]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []])
+ ->assertRedirect(route('projects.wizard', [$project, 'step' => 3]));
+
+ $this->assertEquals(3, $project->fresh()->current_wizard_step);
+ }
+
+ /** @test */
+ public function save_tasks_does_not_downgrade_already_advanced_step(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 5]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []]);
+
+ $this->assertEquals(5, $project->fresh()->current_wizard_step);
+ }
+
+ /** @test */
+ public function save_tasks_prepopulates_8_default_milestones(): void
+ {
+ $project = Project::factory()->create([
+ 'current_wizard_step' => 2,
+ 'start_date' => '2025-01-01',
+ 'contract_duration' => 365,
+ ]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.tasks', $project), ['prepopulate_milestones' => true]);
+
+ $this->assertEquals(8, $project->milestones()->count());
+ }
+
+ /** @test */
+ public function save_tasks_creates_milestones_and_tasks(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 2]);
+
+ $this->actingAs($this->admin)->post(route('projects.wizard.tasks', $project), [
+ 'milestones' => [['name' => 'Phase 1', 'weight_percentage' => 100]],
+ 'tasks' => [['name' => 'Task A', 'description' => 'First task']],
+ ]);
+
+ $this->assertEquals(1, $project->milestones()->count());
+ $this->assertEquals(1, $project->tasks()->count());
+ }
+
+ // ─── STEP 3: Material Estimates ──────────────────────────────────────────
+
+ /** @test */
+ public function save_estimates_fails_without_material_ulid(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 3]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.estimates', $project), [
+ 'estimates' => [['material_ulid' => '', 'estimated_qty' => 10, 'unit_cost' => 100]],
+ ])
+ ->assertSessionHasErrors('estimates.0.material_ulid');
+ }
+
+ /** @test */
+ public function save_estimates_fails_with_negative_quantity(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 3]);
+ $material = Material::factory()->create(['type' => 'single', 'status' => 'active']);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.estimates', $project), [
+ 'estimates' => [['material_ulid' => $material->ulid, 'estimated_qty' => -5, 'unit_cost' => 100]],
+ ])
+ ->assertSessionHasErrors('estimates.0.estimated_qty');
+ }
+
+ /** @test */
+ public function save_estimates_with_empty_array_advances_step(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 3]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.estimates', $project), ['estimates' => []])
+ ->assertRedirect(route('projects.wizard', [$project, 'step' => 4]));
+
+ $this->assertEquals(4, $project->fresh()->current_wizard_step);
+ }
+
+ /** @test */
+ public function save_estimates_creates_material_estimate_record(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 3]);
+ $material = Material::factory()->create(['type' => 'single', 'status' => 'active']);
+
+ $this->actingAs($this->admin)->post(route('projects.wizard.estimates', $project), [
+ 'estimates' => [['material_ulid' => $material->ulid, 'estimated_qty' => 10, 'unit_cost' => 250.00]],
+ ]);
+
+ $this->assertEquals(1, $project->materialsEstimates()->count());
+ $est = $project->materialsEstimates()->first();
+ $this->assertEquals(10, $est->estimated_qty);
+ $this->assertEquals(250.00, $est->unit_cost);
+ }
+
+ // ─── STEP 4: Labor Allocation ────────────────────────────────────────────
+
+ /** @test */
+ public function save_labor_fails_without_task_ulid(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 4]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.labor', $project), [
+ 'labor' => [['task_ulid' => '', 'labor_ulid' => 'x', 'estimated_hours' => 8]],
+ ])
+ ->assertSessionHasErrors('labor.0.task_ulid');
+ }
+
+ /** @test */
+ public function save_labor_fails_with_negative_hours(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 4]);
+ $task = Task::factory()->create(['project_id' => $project->id]);
+ $labor = Labor::factory()->create(['status' => 'active']);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.labor', $project), [
+ 'labor' => [['task_ulid' => $task->ulid, 'labor_ulid' => $labor->ulid, 'estimated_hours' => -2]],
+ ])
+ ->assertSessionHasErrors('labor.0.estimated_hours');
+ }
+
+ /** @test */
+ public function save_labor_with_empty_array_advances_step(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 4]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.labor', $project), ['labor' => []])
+ ->assertRedirect(route('projects.wizard', [$project, 'step' => 5]));
+
+ $this->assertEquals(5, $project->fresh()->current_wizard_step);
+ }
+
+ // ─── STEP 5: Equipment Allocation ────────────────────────────────────────
+
+ /** @test */
+ public function save_equipment_fails_without_equipment_ulid(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 5]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.equipment', $project), [
+ 'equipment' => [['task_ulid' => 'x', 'equipment_ulid' => '', 'estimated_hours' => 4]],
+ ])
+ ->assertSessionHasErrors('equipment.0.equipment_ulid');
+ }
+
+ /** @test */
+ public function save_equipment_with_empty_array_advances_step(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 5]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.equipment', $project), ['equipment' => []])
+ ->assertRedirect(route('projects.wizard', [$project, 'step' => 6]));
+
+ $this->assertEquals(6, $project->fresh()->current_wizard_step);
+ }
+
+ // ─── STEP 6: Submit for Approval ─────────────────────────────────────────
+
+ /** @test */
+ public function submit_fails_without_approvers(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 6]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.submit', $project), ['approver_ids' => []])
+ ->assertSessionHasErrors('approver_ids');
+ }
+
+ /** @test */
+ public function submit_advances_step_to_7(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 6]);
+ $approver = User::factory()->create(['user_type' => 'admin', 'status' => 'active']);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.submit', $project), ['approver_ids' => [$approver->ulid]]);
+
+ $this->assertEquals(7, $project->fresh()->current_wizard_step);
+ }
+
+ // ─── Locked Project Guards (step >= 8) ───────────────────────────────────
+
+ /** @test */
+ public function locked_project_blocks_task_save(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 8]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.tasks', $project), ['milestones' => [], 'tasks' => []])
+ ->assertSessionHas('error');
+ }
+
+ /** @test */
+ public function locked_project_blocks_estimate_save(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 8]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.estimates', $project), ['estimates' => []])
+ ->assertSessionHas('error');
+ }
+
+ /** @test */
+ public function locked_project_blocks_labor_save(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 8]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.labor', $project), ['labor' => []])
+ ->assertSessionHas('error');
+ }
+
+ /** @test */
+ public function locked_project_blocks_equipment_save(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 8]);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.wizard.equipment', $project), ['equipment' => []])
+ ->assertSessionHas('error');
+ }
+
+ /** @test */
+ public function locked_project_blocks_update(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 8]);
+
+ $this->actingAs($this->admin)
+ ->put(route('projects.update', $project), ['name' => 'Changed', 'project_type' => 'standard'])
+ ->assertSessionHas('error');
+
+ $this->assertNotEquals('Changed', $project->fresh()->name);
+ }
+
+ // ─── Project Index Visibility ─────────────────────────────────────────────
+
+ /** @test */
+ public function index_only_shows_completed_wizard_projects(): void
+ {
+ $active = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
+ Project::factory()->create(['current_wizard_step' => 3]);
+
+ $this->actingAs($this->admin)
+ ->get(route('projects.index'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('projects.data', 1)
+ ->where('projects.data.0.id', $active->id)
+ );
+ }
+
+ /** @test */
+ public function drafts_shows_incomplete_wizard_projects(): void
+ {
+ $draft = Project::factory()->create(['current_wizard_step' => 3]);
+
+ $this->actingAs($this->admin)
+ ->get(route('projects.index'))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page
+ ->has('drafts', 1)
+ ->where('drafts.0.id', $draft->id)
+ );
+ }
+
+ // ─── Status Transitions ──────────────────────────────────────────────────
+
+ /** @test */
+ public function cannot_start_project_without_pm(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'planning']);
+
+ $this->actingAs($this->admin)
+ ->patch(route('projects.transition', $project), ['status' => 'in_progress'])
+ ->assertSessionHas('error');
+ }
+
+ /** @test */
+ public function can_start_project_with_pm_assigned(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'planning']);
+ $pm = User::factory()->create(['user_type' => 'admin']);
+ $project->personnel()->attach($pm->id, ['role' => 'pm']);
+
+ $this->actingAs($this->admin)
+ ->patch(route('projects.transition', $project), ['status' => 'in_progress']);
+
+ $this->assertEquals('in_progress', $project->fresh()->status->value);
+ }
+
+ /** @test */
+ public function cannot_complete_parent_with_active_children(): void
+ {
+ $parent = Project::factory()->create(['current_wizard_step' => 8, 'status' => 'in_progress']);
+ $pm = User::factory()->create();
+ $parent->personnel()->attach($pm->id, ['role' => 'pm']);
+
+ Project::factory()->create([
+ 'parent_project_id' => $parent->id,
+ 'current_wizard_step' => 8,
+ 'status' => 'in_progress',
+ ]);
+
+ $this->actingAs($this->admin)
+ ->patch(route('projects.transition', $parent), ['status' => 'completed'])
+ ->assertSessionHas('error');
+
+ $this->assertEquals('in_progress', $parent->fresh()->status->value);
+ }
+
+ // ─── Update Project ───────────────────────────────────────────────────────
+
+ /** @test */
+ public function update_succeeds_on_unlocked_project(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 5, 'status' => 'planning']);
+
+ $this->actingAs($this->admin)
+ ->put(route('projects.update', $project), ['name' => 'New Name', 'project_type' => 'standard']);
+
+ $this->assertEquals('New Name', $project->fresh()->name);
+ }
+
+ // ─── Personnel Management ─────────────────────────────────────────────────
+
+ /** @test */
+ public function add_personnel_fails_with_nonexistent_user(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.personnel.add', $project), ['user_id' => 'bad-ulid', 'role' => 'pm'])
+ ->assertSessionHas('error');
+ }
+
+ /** @test */
+ public function add_personnel_fails_with_invalid_role(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
+ $user = User::factory()->create();
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.personnel.add', $project), ['user_id' => $user->ulid, 'role' => 'bad_role'])
+ ->assertSessionHasErrors('role');
+ }
+
+ /** @test */
+ public function add_personnel_fails_on_duplicate(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
+ $user = User::factory()->create();
+ $project->personnel()->attach($user->id, ['role' => 'member']);
+
+ $this->actingAs($this->admin)
+ ->post(route('projects.personnel.add', $project), ['user_id' => $user->ulid, 'role' => 'member'])
+ ->assertSessionHas('error');
+ }
+
+ /** @test */
+ public function can_remove_personnel(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 7, 'status' => 'planning']);
+ $user = User::factory()->create();
+ $project->personnel()->attach($user->id, ['role' => 'member']);
+
+ $this->actingAs($this->admin)
+ ->delete(route('projects.personnel.remove', [$project, $user]));
+
+ $this->assertFalse($project->personnel()->where('user_id', $user->id)->exists());
+ }
+
+ // ─── Wizard Step Clamping ─────────────────────────────────────────────────
+
+ /** @test */
+ public function cannot_skip_ahead_wizard_steps(): void
+ {
+ $project = Project::factory()->create(['current_wizard_step' => 2]);
+
+ $this->actingAs($this->admin)
+ ->get(route('projects.wizard', [$project, 'step' => 5]))
+ ->assertOk()
+ ->assertInertia(fn ($page) => $page->where('step', 2));
+ }
+}