Skip to content

Commit 9ba6746

Browse files
committed
feat: Phase 20 — Company Settings (profile, currency, timezone, logo)
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent eb37e2c commit 9ba6746

7 files changed

Lines changed: 382 additions & 0 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Modules\Core\Models\Tenant;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Storage;
8+
use Inertia\Inertia;
9+
10+
class CompanySettingsController extends Controller
11+
{
12+
private function authorizeAdmin(Request $request): void
13+
{
14+
if (! $request->user()->hasAnyRole(['super-admin', 'admin'])) {
15+
abort(403);
16+
}
17+
}
18+
19+
public function show(Request $request)
20+
{
21+
$this->authorizeAdmin($request);
22+
23+
$tenant = Tenant::findOrFail($request->user()->tenant_id);
24+
25+
$timezones = \DateTimeZone::listIdentifiers();
26+
$currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CHF', 'CNY', 'INR', 'SGD',
27+
'AED', 'SAR', 'BRL', 'MXN', 'ZAR', 'NOK', 'SEK', 'DKK', 'NZD', 'HKD'];
28+
$dateFormats = [
29+
'Y-m-d' => now()->format('Y-m-d') . ' (YYYY-MM-DD)',
30+
'd/m/Y' => now()->format('d/m/Y') . ' (DD/MM/YYYY)',
31+
'm/d/Y' => now()->format('m/d/Y') . ' (MM/DD/YYYY)',
32+
'd-m-Y' => now()->format('d-m-Y') . ' (DD-MM-YYYY)',
33+
'd M Y' => now()->format('d M Y') . ' (DD Mon YYYY)',
34+
];
35+
36+
return Inertia::render('Settings/Company', [
37+
'tenant' => $tenant->only([
38+
'id', 'name', 'slug', 'email', 'phone', 'address', 'city',
39+
'country', 'currency_code', 'timezone', 'date_format', 'logo_path',
40+
]),
41+
'timezones' => $timezones,
42+
'currencies' => $currencies,
43+
'dateFormats' => $dateFormats,
44+
]);
45+
}
46+
47+
public function update(Request $request)
48+
{
49+
$this->authorizeAdmin($request);
50+
51+
$tenant = Tenant::findOrFail($request->user()->tenant_id);
52+
53+
$data = $request->validate([
54+
'name' => 'required|string|max:191',
55+
'email' => 'nullable|email|max:191',
56+
'phone' => 'nullable|string|max:50',
57+
'address' => 'nullable|string|max:500',
58+
'city' => 'nullable|string|max:100',
59+
'country' => 'nullable|string|max:100',
60+
'currency_code' => 'required|string|size:3',
61+
'timezone' => 'required|string|timezone',
62+
'date_format' => 'required|string|in:Y-m-d,d/m/Y,m/d/Y,d-m-Y,d M Y',
63+
]);
64+
65+
$tenant->update($data);
66+
67+
return back()->with('success', 'Company settings saved.');
68+
}
69+
70+
public function uploadLogo(Request $request)
71+
{
72+
$this->authorizeAdmin($request);
73+
74+
$request->validate(['logo' => 'required|image|mimes:png,jpg,jpeg,gif,svg|max:1024']);
75+
76+
$tenant = Tenant::findOrFail($request->user()->tenant_id);
77+
78+
// Delete old logo if exists
79+
if ($tenant->logo_path && Storage::disk('public')->exists($tenant->logo_path)) {
80+
Storage::disk('public')->delete($tenant->logo_path);
81+
}
82+
83+
$path = $request->file('logo')->store('logos', 'public');
84+
$tenant->update(['logo_path' => $path]);
85+
86+
return back()->with('success', 'Logo uploaded.');
87+
}
88+
}

erp/app/Modules/Core/Models/Tenant.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ class Tenant extends Model
1818
'domain',
1919
'settings',
2020
'is_active',
21+
'email',
22+
'phone',
23+
'address',
24+
'city',
25+
'country',
26+
'currency_code',
27+
'timezone',
28+
'date_format',
29+
'logo_path',
2130
];
2231

2332
protected $casts = [
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('tenants', function (Blueprint $table) {
15+
$table->string('email')->nullable()->after('slug');
16+
$table->string('phone')->nullable()->after('email');
17+
$table->text('address')->nullable()->after('phone');
18+
$table->string('city')->nullable()->after('address');
19+
$table->string('country')->nullable()->after('city');
20+
$table->string('currency_code', 3)->default('USD')->after('country');
21+
$table->string('timezone')->default('UTC')->after('currency_code');
22+
$table->string('date_format')->default('Y-m-d')->after('timezone');
23+
$table->string('logo_path')->nullable()->after('date_format');
24+
});
25+
}
26+
27+
/**
28+
* Reverse the migrations.
29+
*/
30+
public function down(): void
31+
{
32+
Schema::table('tenants', function (Blueprint $table) {
33+
$table->dropColumn([
34+
'email', 'phone', 'address', 'city', 'country',
35+
'currency_code', 'timezone', 'date_format', 'logo_path',
36+
]);
37+
});
38+
}
39+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ const navItems: NavItem[] = [
131131
permission: 'roles.manage',
132132
children: [
133133
{ label: 'Users', href: '/settings/users', icon: <span /> },
134+
{ label: 'Company', href: '/settings/company', icon: <span /> },
134135
],
135136
},
136137
];
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Head, useForm, router } from '@inertiajs/react';
2+
import { useState } from 'react';
3+
import AppLayout from '@/Layouts/AppLayout';
4+
import { Button } from '@/Components/Common/Button';
5+
import type { PageProps } from '@/types';
6+
7+
interface Tenant {
8+
id: number;
9+
name: string;
10+
slug: string;
11+
email: string | null;
12+
phone: string | null;
13+
address: string | null;
14+
city: string | null;
15+
country: string | null;
16+
currency_code: string;
17+
timezone: string;
18+
date_format: string;
19+
logo_path: string | null;
20+
}
21+
22+
interface Props extends PageProps {
23+
tenant: Tenant;
24+
timezones: string[];
25+
currencies: string[];
26+
dateFormats: Record<string, string>;
27+
}
28+
29+
export default function CompanySettings({ tenant, timezones, currencies, dateFormats }: Props) {
30+
const { data, setData, patch, processing, errors } = useForm({
31+
name: tenant.name,
32+
email: tenant.email ?? '',
33+
phone: tenant.phone ?? '',
34+
address: tenant.address ?? '',
35+
city: tenant.city ?? '',
36+
country: tenant.country ?? '',
37+
currency_code: tenant.currency_code,
38+
timezone: tenant.timezone,
39+
date_format: tenant.date_format,
40+
});
41+
42+
const [logoFile, setLogoFile] = useState<File | null>(null);
43+
44+
function submitSettings(e: React.FormEvent) {
45+
e.preventDefault();
46+
patch('/settings/company');
47+
}
48+
49+
function submitLogo(e: React.FormEvent) {
50+
e.preventDefault();
51+
if (!logoFile) return;
52+
const form = new FormData();
53+
form.append('logo', logoFile);
54+
router.post('/settings/company/logo', form);
55+
}
56+
57+
return (
58+
<AppLayout>
59+
<Head title="Company Settings" />
60+
<div className="max-w-2xl space-y-8">
61+
<h1 className="text-2xl font-semibold text-slate-900">Company Settings</h1>
62+
63+
<form onSubmit={submitSettings} className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm space-y-5">
64+
<h2 className="text-sm font-semibold text-slate-700 border-b border-slate-100 pb-2">Company Profile</h2>
65+
66+
<div className="grid grid-cols-2 gap-4">
67+
<div>
68+
<label className="block text-xs font-medium text-slate-600 mb-1">Company Name *</label>
69+
<input type="text" required value={data.name} onChange={(e) => setData('name', e.target.value)}
70+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
71+
{errors.name && <p className="text-xs text-red-600 mt-1">{errors.name}</p>}
72+
</div>
73+
<div>
74+
<label className="block text-xs font-medium text-slate-600 mb-1">Email</label>
75+
<input type="email" value={data.email} onChange={(e) => setData('email', e.target.value)}
76+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
77+
</div>
78+
<div>
79+
<label className="block text-xs font-medium text-slate-600 mb-1">Phone</label>
80+
<input type="text" value={data.phone} onChange={(e) => setData('phone', e.target.value)}
81+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
82+
</div>
83+
<div>
84+
<label className="block text-xs font-medium text-slate-600 mb-1">City</label>
85+
<input type="text" value={data.city} onChange={(e) => setData('city', e.target.value)}
86+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
87+
</div>
88+
<div>
89+
<label className="block text-xs font-medium text-slate-600 mb-1">Country</label>
90+
<input type="text" value={data.country} onChange={(e) => setData('country', e.target.value)}
91+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
92+
</div>
93+
</div>
94+
95+
<div>
96+
<label className="block text-xs font-medium text-slate-600 mb-1">Address</label>
97+
<textarea rows={3} value={data.address} onChange={(e) => setData('address', e.target.value)}
98+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
99+
</div>
100+
101+
<h2 className="text-sm font-semibold text-slate-700 border-b border-slate-100 pb-2 pt-2">Localisation</h2>
102+
103+
<div className="grid grid-cols-3 gap-4">
104+
<div>
105+
<label className="block text-xs font-medium text-slate-600 mb-1">Base Currency *</label>
106+
<select value={data.currency_code} onChange={(e) => setData('currency_code', e.target.value)}
107+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
108+
{currencies.map((c) => <option key={c} value={c}>{c}</option>)}
109+
</select>
110+
</div>
111+
<div>
112+
<label className="block text-xs font-medium text-slate-600 mb-1">Timezone *</label>
113+
<select value={data.timezone} onChange={(e) => setData('timezone', e.target.value)}
114+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
115+
{timezones.map((tz) => <option key={tz} value={tz}>{tz}</option>)}
116+
</select>
117+
</div>
118+
<div>
119+
<label className="block text-xs font-medium text-slate-600 mb-1">Date Format *</label>
120+
<select value={data.date_format} onChange={(e) => setData('date_format', e.target.value)}
121+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
122+
{Object.entries(dateFormats).map(([fmt, label]) => (
123+
<option key={fmt} value={fmt}>{label}</option>
124+
))}
125+
</select>
126+
</div>
127+
</div>
128+
129+
<div className="flex justify-end pt-2">
130+
<Button type="submit" disabled={processing}>{processing ? 'Saving…' : 'Save Settings'}</Button>
131+
</div>
132+
</form>
133+
134+
{/* Logo upload */}
135+
<form onSubmit={submitLogo} className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm space-y-4">
136+
<h2 className="text-sm font-semibold text-slate-700 border-b border-slate-100 pb-2">Company Logo</h2>
137+
{tenant.logo_path && (
138+
<img src={`/storage/${tenant.logo_path}`} alt="Company logo" className="h-16 object-contain rounded" />
139+
)}
140+
<div>
141+
<input type="file" accept="image/*" onChange={(e) => setLogoFile(e.target.files?.[0] ?? null)}
142+
className="text-sm text-slate-600" />
143+
<p className="mt-1 text-xs text-slate-400">PNG, JPG, GIF or SVG · max 1 MB</p>
144+
</div>
145+
<div className="flex justify-end">
146+
<Button type="submit" variant="secondary" disabled={!logoFile}>Upload Logo</Button>
147+
</div>
148+
</form>
149+
</div>
150+
</AppLayout>
151+
);
152+
}

erp/routes/web.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php
22

3+
use App\Http\Controllers\CompanySettingsController;
34
use App\Http\Controllers\DashboardController;
45
use App\Http\Controllers\ProfileController;
56
use App\Http\Controllers\UserManagementController;
@@ -32,6 +33,10 @@
3233
Route::patch('users/{user}/role', [UserManagementController::class, 'updateRole'])->name('settings.users.update-role');
3334
Route::patch('users/{user}/toggle-active', [UserManagementController::class, 'toggleActive'])->name('settings.users.toggle-active');
3435
Route::delete('users/{user}', [UserManagementController::class, 'destroy'])->name('settings.users.destroy');
36+
37+
Route::get('company', [CompanySettingsController::class, 'show'])->name('settings.company.show');
38+
Route::patch('company', [CompanySettingsController::class, 'update'])->name('settings.company.update');
39+
Route::post('company/logo', [CompanySettingsController::class, 'uploadLogo'])->name('settings.company.logo');
3540
});
3641

3742
require __DIR__ . '/auth.php';

0 commit comments

Comments
 (0)